feat(render-service): add POST /preview endpoint (fixes render_scene_preview 404) (#1285)

* feat(render-service): add PreviewGate admission control for previews

Independent admission gate (global in-flight cap + per-identity cap) so
synchronous previews never compete with the MP4 export queue. Release is
idempotent so every route exit path can call it safely.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(render-service): add single-page preview renderer

Renders a scene to a PNG via SlideCanvas + static markup using the existing
Chromium executable configuration, egress policy, and semaphore
infrastructure, with a deadline/AbortSignal.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(render-service): add preview config and resource pixel limits

Adds previewDeadlineMs / previewMaxInFlight / previewMaxPerUser config and
resource-profile viewport pixel limits for previews; declares the renderer
runtime dependencies installed with the render-service container.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(render-service): add POST /preview endpoint

Wires the preview endpoint: declared-size rejection, gate admission before
buffering, byte-capped payload parsing, scene/stage/viewport validation,
deadline/cancellation, PNG response, and 400/413/429/504/500 mapping. Fixes
the render_scene_preview tool's broken link (previously 404).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix preview rendering and deadlines

* fix(render-service): enforce preview deadline, memory bound, and shared Chromium limit

- Stalled preview uploads now observe the deadline signal, error the consumer,
  and cancel the reader; deadline aborts map to 504 and release the permit.
- Previews retain the extraction permit through rendering so parsed payloads
  cannot accumulate beyond the memory admission bound.
- RenderCoordinator owns a shared execution semaphore so previews and MP4
  renders respect the same global Chromium concurrency limit.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(render-service): harden preview admission and rendering

* docs(render-service): define the preview deployment contract

* fix(render-service): enforce self-contained previews

* fix(render-service): allow fragment CSS URLs in previews

* fix(render-service): allow background-only previews

* fix(render-service): control malformed preview rejections

* style(render-service): apply prettier formatting after rebase

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
wyuc
2026-09-03 07:00:27 -04:00
committed by GitHub
co-authored by Claude Code
parent dd74cfe23b
commit 0128212da4
23 changed files with 3338 additions and 55 deletions
+6
View File
@@ -115,6 +115,12 @@ services:
# RENDER_MAX_QUEUE cap. Set this >0 only with a trusted proxy supplying
# per-user identity (see render-service/README.md).
- RENDER_MAX_JOBS_PER_USER=0
- RENDER_PREVIEW_TIMEOUT_MS=${RENDER_PREVIEW_TIMEOUT_MS:-20000}
- RENDER_PREVIEW_MAX_IN_FLIGHT=${RENDER_PREVIEW_MAX_IN_FLIGHT:-8}
# Preview callers send a durable owner identity in x-openmaic-client, so
# keep this per-owner guard enabled. Set it to 0 only in deployments that
# call /preview without an owner identity.
- RENDER_PREVIEW_MAX_PER_USER=${RENDER_PREVIEW_MAX_PER_USER:-2}
# Bound RAM: each render (Chromium + FFmpeg + archive expansion) is memory-heavy.
# The standard profile requires 8 GiB. For the explicit low-memory profile,
# set RENDER_RESOURCE_PROFILE=low-memory and RENDER_SERVICE_MEMORY_LIMIT=4g.
+46 -11
View File
@@ -21,6 +21,7 @@ poll, then download. Job ids are opaque.
| Method + path | Purpose |
| ----------------------------- | ------------------------------------------------------------------------------------ |
| `POST /render` | multipart: `project` (the ZIP) + `fps`, `quality`, `format` fields → `202 { jobId }` |
| `POST /preview` | JSON: persisted `scene`, stage context, and viewport → synchronous PNG |
| `GET /render/:jobId` | status/progress plus actual capture, worker, profile, and runtime metrics |
| `GET /render/:jobId/download` | stream the MP4 (or `302` to a presigned URL) once `succeeded` |
| `DELETE /render/:jobId` | cancel a queued/running job |
@@ -39,13 +40,38 @@ JSON body `{ error, reason }` where `reason` is one of:
- `per_identity_limit` — this client identity already holds
`RENDER_MAX_JOBS_PER_USER` active renders.
`POST /preview` also answers `429` with `{ error, reason }`, where `reason` is
one of:
- `preview_queue_full` — `RENDER_PREVIEW_MAX_IN_FLIGHT` previews are already
live (buffering or executing).
- `preview_per_user_limit` — this client identity already holds
`RENDER_PREVIEW_MAX_PER_USER` previews.
- `capacity_busy` — the shared execution slot is occupied, including by a video
render. Previews never queue for this slot; retry later.
`GET /health` reports `accepting: boolean` — whether the queue cap currently has
room for another job — so callers and load balancers can shed traffic before
probing with a real render. The flag is deliberately aggregate-only: no queue
depths or occupancy counts, and never any per-identity data. Identity keys are
client IPs when the service runs behind `TRUST_PROXY_HEADERS=true`, so exposing
the per-identity map would publish the IP address of everyone currently
rendering.
room for another **video render** — it reflects the render queue only, not
preview admission or execution-slot availability. Callers and load balancers
can shed traffic before probing with a real render. The flag is deliberately
aggregate-only: no queue depths or occupancy counts, and never any per-identity
data. Identity keys are client IPs when the service runs behind
`TRUST_PROXY_HEADERS=true`, so exposing the per-identity map would publish the
IP address of everyone currently rendering.
Previews share the service's single Chromium execution slot with video renders,
but never wait in the video queue. Once a preview has passed its body and
semantic checks, it renders immediately or fails with `capacity_busy`.
`/preview` requires fully self-contained scenes (inline code and data: URLs
only); network, blob:, and relative references are rejected. Slide media must
use `data:` URLs, and interactive scenes must contain embedded HTML whose
resource references are inline or `data:` URLs. Non-self-contained inputs fail
with `422` instead of returning a misleading PNG. Detection is best-effort:
some exotic external constructs (for example, `object data`, `track src`, and
CSS `@import`) may pass validation; self-contained scenes are the caller's
responsibility, and end-to-end fidelity for persisted scenes arrives with
caller-side preparation (tracked separately).
## Environment
@@ -59,17 +85,16 @@ rendering.
| `RENDER_MAX_QUEUE` | `20` | Max jobs in the system (reserved+queued+running) before new submits get `429`. |
| `RENDER_JOB_TTL_MS` | `1800000` | How long finished jobs + artifacts live before cleanup. |
| `RENDER_JOB_DEADLINE_MS` | `2700000` | Hard per-job wall-clock deadline; overruns are aborted and marked **failed**. |
| `RENDER_PREVIEW_TIMEOUT_MS` | `20000` | Hard wall-clock deadline for a synchronous preview, including body parsing and Chromium cleanup. |
| `RENDER_PREVIEW_MAX_IN_FLIGHT` | `8` | Maximum admitted previews across buffering and execution. |
| `RENDER_PREVIEW_MAX_PER_USER` | `2` | Concurrent previews per owner identity; 0 disables the guard for deployments whose preview callers do not supply an owner identity (see note below). |
| `RENDER_PREVIEW_MAX_JSON_BYTES` | `33554432` | Maximum preview JSON body size (32 MiB), enforced on declared length and streamed bytes independently of the ZIP upload cap. |
| `RENDER_CHUNK_EXECUTION` | `false` | Opt in to the bounded local `plan -> renderChunk -> assemble` executor. The HTTP API stays unchanged. |
| `RENDER_CHUNK_COUNT` | `1` | Number of deterministic closed-GOP chunks planned for a render when chunk execution is enabled. |
| `RENDER_CHUNK_WORKERS` | profile: `1` | Maximum producer capture workers inside one chunk. This remains explicit so chunk fan-out cannot multiply nested producer workers. |
| `RENDER_MAX_PARALLEL_CHUNKS` | `1` | Maximum chunks executed concurrently in the local process. Chunks beyond the bound wait locally. |
| `RENDER_CHUNK_SIZE_FRAMES` | unset | Optional fixed frame count per planned chunk. |
| `RENDER_TARGET_CHUNK_FRAMES` | unset | Optional target frame count used by the producer planner when deriving chunk boundaries. |
Chunk settings are profile-bounded: the standard profile allows at most one
producer worker per chunk and four concurrent chunks; low-memory allows one of
each. Values above the selected profile limit fail startup rather than
silently multiplying browser, FFmpeg, and temporary-disk usage.
| `RENDER_MAX_UPLOAD_BYTES` | `314572800` | Max compressed archive size accepted (300 MB); enforced on real bytes, before buffering. |
| `RENDER_MAX_ENTRIES` | `5000` | Max entries allowed in the archive. |
| `RENDER_MAX_ENTRY_BYTES` | `209715200` | Max expanded size of any single entry (200 MB). |
@@ -88,6 +113,11 @@ silently multiplying browser, FFmpeg, and temporary-disk usage.
| `RENDER_HOME` | `/app` | Writable home used after the entrypoint drops privileges. Producer font caches live under `$RENDER_HOME/.cache`, never `/root/.cache`. |
| `PUPPETEER_EXECUTABLE_PATH` | `/usr/bin/chromium-headless-shell` | System Chromium headless shell (set in the image). |
Chunk settings are profile-bounded: the standard profile allows at most one
producer worker per chunk and four concurrent chunks; low-memory allows one of
each. Values above the selected profile limit fail startup rather than
silently multiplying browser, FFmpeg, and temporary-disk usage.
Client identity for the per-user guard is taken from the `x-openmaic-client`
header, which the app's proxy sets. A client-supplied `userId` form field is
ignored. The app derives that header from `x-forwarded-for`/`x-real-ip` **only
@@ -104,6 +134,11 @@ forwarding headers.
> on `RENDER_MAX_CONCURRENCY` + `RENDER_MAX_QUEUE` (see the comment beside that
> variable in the Compose file). Enable the per-user guard only behind a trusted
> proxy that supplies a real per-user identity.
>
> Preview callers are different: the app sends the durable session owner id in
> `x-openmaic-client`, so `RENDER_PREVIEW_MAX_PER_USER` stays enabled and
> defaults to 2. Set it to 0 only for deployments that call `/preview` without
> an owner identity.
## Security / isolation
+870
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -16,12 +16,25 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"@hyperframes/producer": "^0.7.107",
"@openmaic/dsl": "0.11.0",
"@openmaic/renderer": "0.1.4",
"echarts": "^6.0.0",
"esbuild": "0.28.1",
"fflate": "^0.8.2",
"hono": "^4.6.14",
"motion": "^12.27.5",
"parse5": "^8.0.1",
"puppeteer-core": "^25.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"shiki": "^3.21.0",
"tailwindcss": "^4.1.18",
"tsx": "^4.19.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
},
+49 -13
View File
@@ -16,28 +16,64 @@ export interface CappedBody {
exceeded: () => boolean;
}
export function capBodyStream(body: ReadableStream<Uint8Array>, capBytes: number): CappedBody {
export function capBodyStream(
body: ReadableStream<Uint8Array>,
capBytes: number,
signal?: AbortSignal,
): CappedBody {
let total = 0;
let tripped = false;
let stopped = false;
const reader = body.getReader();
let onAbort: (() => void) | undefined;
const cleanup = () => {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
if (!signal) return;
onAbort = () => {
if (stopped) return;
stopped = true;
cleanup();
controller.error(signal.reason ?? new Error('Operation aborted'));
void reader.cancel(signal.reason).catch(() => {});
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
},
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
try {
const { done, value } = await reader.read();
if (stopped) return;
if (done) {
stopped = true;
cleanup();
controller.close();
return;
}
total += value.byteLength;
if (total > capBytes) {
tripped = true;
stopped = true;
cleanup();
controller.error(new Error('Upload exceeds the maximum allowed size'));
await reader.cancel().catch(() => {});
return;
}
controller.enqueue(value);
} catch (error) {
if (stopped) return;
stopped = true;
cleanup();
controller.error(error);
}
total += value.byteLength;
if (total > capBytes) {
tripped = true;
controller.error(new Error('Upload exceeds the maximum allowed size'));
await reader.cancel().catch(() => {});
return;
}
controller.enqueue(value);
},
cancel(reason) {
stopped = true;
cleanup();
void reader.cancel(reason).catch(() => {});
},
});
+8
View File
@@ -86,6 +86,14 @@ export const config = {
* marked failed so a hung job can't hold a concurrency slot + scratch forever.
*/
jobDeadlineMs: intEnv('RENDER_JOB_DEADLINE_MS', 45 * 60 * 1000),
/** Independent wall-clock deadline for synchronous single-page previews. */
previewDeadlineMs: intEnv('RENDER_PREVIEW_TIMEOUT_MS', 20 * 1000),
/** Total previews admitted at once (buffering + executing); excess requests fast-fail. */
previewMaxInFlight: intEnv('RENDER_PREVIEW_MAX_IN_FLIGHT', 8),
/** Per-owner concurrent preview cap; 0 disables the per-owner check. */
previewMaxPerUser: intEnvAllowZero('RENDER_PREVIEW_MAX_PER_USER', 2),
/** Max JSON request size for a synchronous preview (bytes). */
previewMaxJsonBytes: intEnv('RENDER_PREVIEW_MAX_JSON_BYTES', 32 * MB),
/** Root dir for unzipped projects and rendered outputs. */
tmpDir: process.env.PRODUCER_TMP_PROJECT_DIR || '/tmp/openmaic-renders',
+224 -4
View File
@@ -10,6 +10,7 @@
* demo-scale deployment without the app noticing:
*
* POST /render multipart: project(zip) + fps/quality/format → 202 { jobId }
* POST /preview JSON: scene + stage + viewport → PNG
* GET /render/:jobId → { status, progress, currentStage, done, ... }
* GET /render/:jobId/download → stream MP4 (or 302 to a presigned URL)
* DELETE /render/:jobId → cancel
@@ -25,7 +26,8 @@ import { createReadStream } from 'node:fs';
import { mkdir, stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { validateScene } from '@openmaic/dsl';
import { Hono, type Context } from 'hono';
import { config } from './config.js';
import { InMemoryJobStore } from './job-store.js';
import { LocalDiskArtifactStore } from './artifact-store.js';
@@ -38,6 +40,17 @@ import { InProcessExecutor } from './render-executor.js';
import { InvalidProjectError, unzipProject as defaultUnzipProject } from './unzip.js';
import { capBodyStream } from './capped-stream.js';
import { Semaphore } from './semaphore.js';
import { PreviewGate, PreviewRejectedError } from './preview-gate.js';
import {
ChromiumPreviewRenderer,
PreviewTimeoutError,
buildSlideClientBundle,
type PreviewRenderer,
type PreviewScene,
type PreviewStageContext,
type PreviewViewport,
} from './preview-renderer.js';
import { invalidSlideCanvasElementError, previewabilityError } from './preview-validation.js';
import type { JobStore } from './job-store.js';
import type { ArtifactStore } from './artifact-store.js';
import { isTerminal, type RenderOptions } from './types.js';
@@ -49,9 +62,11 @@ import type { RuntimeVersions } from './types.js';
class UploadTooLargeError extends Error {}
/** Thrown inside the gated section for a malformed request (→ HTTP 400). */
class BadRequestError extends Error {}
/** Thrown for a valid payload whose scene cannot produce a faithful preview (→ HTTP 422). */
class UnprocessablePreviewError extends Error {}
/** 429 body for an admission rejection: the prose plus its machine code, if any. */
function rejectionBody(error: RenderRejectedError): { error: string; reason?: string } {
function rejectionBody(error: Error & { reason?: string }): { error: string; reason?: string } {
// Spread-omission keeps reason-less rejections (internal invariants) from
// serializing `"reason": undefined` into the body.
return { error: error.message, ...(error.reason ? { reason: error.reason } : {}) };
@@ -64,6 +79,14 @@ export interface AppDeps {
coordinator: RenderCoordinator;
/** Bounds concurrent *buffering + extraction* (the whole RAM-heavy section). */
extractionGate: Semaphore;
/** Independent preview admission, injectable for focused route tests. */
previewGate?: PreviewGate;
/** Render one validated persisted scene to PNG. */
previewRenderer?: PreviewRenderer;
/** Preview wall-clock deadline, injectable for focused route tests. */
previewDeadlineMs?: number;
/** Preview JSON byte ceiling, injectable for focused route tests. */
previewMaxJsonBytes?: number;
/** Extract a validated archive into a dir. Overridable in tests. */
unzipProject?: (zip: Uint8Array, destDir: string) => Promise<void>;
/** Create a fresh per-render scratch dir. Overridable in tests. */
@@ -72,6 +95,108 @@ export interface AppDeps {
runtimeVersions?: RuntimeVersions;
}
interface PreviewPayload {
version: 1;
scene: PreviewScene;
stage: PreviewStageContext;
viewport: PreviewViewport;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parsePreviewPayload(value: unknown): PreviewPayload | string {
if (!isRecord(value)) return 'Expected a JSON object';
if (value.version !== 1) return 'Unsupported preview payload version';
const sceneValidation = validateScene(value.scene);
if (!sceneValidation.valid) {
const issue = sceneValidation.errors[0];
return issue ? `Invalid scene at ${issue.path || '/'}: ${issue.message}` : 'Invalid scene';
}
const invalidCanvasElement = invalidSlideCanvasElementError(value.scene);
if (invalidCanvasElement) return invalidCanvasElement;
const scene = value.scene as PreviewScene;
if (!isRecord(value.stage)) return 'Invalid stage context';
const stageId = value.stage.id;
const stageName = value.stage.name;
if (typeof stageId !== 'string' || !stageId.trim() || stageId.length > 256) {
return 'Invalid stage id';
}
if (typeof stageName !== 'string' || !stageName.trim() || stageName.length > 10_000) {
return 'Invalid stage name';
}
if (stageId !== scene.stageId) return 'Stage context does not match scene.stageId';
if (!isRecord(value.viewport)) return 'Invalid viewport';
const { width, height, deviceScaleFactor } = value.viewport;
if (typeof width !== 'number' || !Number.isInteger(width) || width < 64 || width > 4096) {
return 'Invalid viewport width';
}
if (typeof height !== 'number' || !Number.isInteger(height) || height < 64 || height > 4096) {
return 'Invalid viewport height';
}
if (
typeof deviceScaleFactor !== 'number' ||
!Number.isFinite(deviceScaleFactor) ||
deviceScaleFactor <= 0 ||
deviceScaleFactor > config.resourceProfile.maxPreviewDeviceScaleFactor
) {
return `Invalid deviceScaleFactor (maximum ${config.resourceProfile.maxPreviewDeviceScaleFactor} for ${config.resourceProfile.name})`;
}
const pixels = width * height * deviceScaleFactor * deviceScaleFactor;
if (pixels > config.resourceProfile.maxPreviewPixels) {
return `Preview exceeds the ${config.resourceProfile.name} resource profile pixel limit`;
}
return {
version: 1,
scene,
stage: { id: stageId, name: stageName },
viewport: { width, height, deviceScaleFactor },
};
}
/** Buffer and parse a byte-capped JSON body while preserving socket backpressure. */
async function readPreviewPayload(
c: Context,
signal: AbortSignal,
maxJsonBytes: number,
): Promise<unknown> {
if (!c.req.header('content-type')?.toLowerCase().includes('application/json')) {
throw new BadRequestError('Expected application/json');
}
const raw = c.req.raw;
let value: unknown;
let capped: ReturnType<typeof capBodyStream> | undefined;
try {
if (raw.body) {
capped = capBodyStream(raw.body, maxJsonBytes, signal);
const bounded = new Request(raw.url, {
method: raw.method,
headers: raw.headers,
body: capped.stream,
signal,
duplex: 'half',
} as RequestInit);
value = await bounded.json();
} else {
signal.throwIfAborted();
value = await c.req.json();
}
} catch {
if (capped?.exceeded()) throw new UploadTooLargeError('Upload too large');
if (signal.aborted)
throw signal.reason ?? new PreviewTimeoutError('Preview exceeded the deadline');
throw new BadRequestError('Expected valid JSON');
}
return value;
}
/** Parse + validate the multipart render options. Returns options or an error string. */
function parseOptions(form: FormData): RenderOptions | string {
const fps = Number.parseInt(String(form.get('fps') ?? '30'), 10);
@@ -92,8 +217,8 @@ function parseOptions(form: FormData): RenderOptions | string {
* Build the render-service HTTP app over injected collaborators.
*
* Admission ordering is the security boundary here:
* 1. `reserve()` (queue + per-identity) runs FIRST, before anything is read —
* a rejected caller never buffers a byte.
* 1. Each route's admission gate runs FIRST, before anything is read — a
* rejected caller never buffers a byte.
* 2. The whole RAM-heavy section — buffering the multipart (`formData()` is
* what materializes the uploaded file into memory), parsing, reading the
* file bytes, and extracting — runs INSIDE `extractionGate`. So at most
@@ -105,6 +230,11 @@ export function createApp(deps: AppDeps): Hono {
const { jobs, artifacts, coordinator, extractionGate } = deps;
const unzipProject = deps.unzipProject ?? defaultUnzipProject;
const makeProjectDir = deps.makeProjectDir ?? defaultMakeProjectDir;
const previewRenderer = deps.previewRenderer ?? new ChromiumPreviewRenderer();
const previewDeadlineMs = deps.previewDeadlineMs ?? config.previewDeadlineMs;
const previewMaxJsonBytes = deps.previewMaxJsonBytes ?? config.previewMaxJsonBytes;
const previewGate =
deps.previewGate ?? new PreviewGate(config.previewMaxInFlight, config.previewMaxPerUser);
const app = new Hono();
@@ -200,6 +330,92 @@ export function createApp(deps: AppDeps): Hono {
}
});
app.post('/preview', async (c) => {
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > previewMaxJsonBytes) {
return c.json({ error: 'Upload too large' }, 413);
}
const identity = c.req.header('x-openmaic-client')?.trim() || 'anonymous';
let release: () => void;
try {
release = previewGate.acquire(identity);
} catch (error) {
if (error instanceof PreviewRejectedError) return c.json(rejectionBody(error), 429);
throw error;
}
const deadlineAbort = new AbortController();
const deadline = setTimeout(
() => deadlineAbort.abort(new PreviewTimeoutError('Preview exceeded the deadline')),
previewDeadlineMs,
);
deadline.unref?.();
// The Fetch request signal is backed by the Node request's close event, so
// disconnecting clients abort body reads and Chromium work immediately.
const signal = AbortSignal.any([c.req.raw.signal, deadlineAbort.signal]);
try {
// The extraction permit covers only byte buffering + JSON parse. A parsed
// scene is bounded by previewMaxJsonBytes times the parser's expansion,
// and PreviewGate bounds live parsed scenes to previewMaxInFlight.
const value = await extractionGate.run(
() => readPreviewPayload(c, signal, previewMaxJsonBytes),
signal,
);
const payload = parsePreviewPayload(value);
if (typeof payload === 'string') throw new BadRequestError(payload);
const unpreviewable = previewabilityError(payload.scene);
if (unpreviewable) throw new UnprocessablePreviewError(unpreviewable);
const execution = coordinator.tryRunWithExecutionSlot(
() =>
previewRenderer.render({
scene: payload.scene,
stage: payload.stage,
viewport: payload.viewport,
signal,
deadlineMs: previewDeadlineMs,
}),
signal,
);
if (!execution) {
throw new PreviewRejectedError(
'Preview capacity is busy with another render; retry shortly.',
'capacity_busy',
);
}
const png = await execution;
if (png.byteLength === 0) throw new Error('Preview renderer returned an empty image');
const body = new Uint8Array(png.byteLength);
body.set(png);
return new Response(body.buffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Content-Length': String(png.byteLength),
},
});
} catch (error) {
if (error instanceof UploadTooLargeError) return c.json({ error: error.message }, 413);
if (error instanceof BadRequestError) return c.json({ error: error.message }, 400);
if (error instanceof UnprocessablePreviewError) {
return c.json({ error: error.message }, 422);
}
if (error instanceof PreviewRejectedError) return c.json(rejectionBody(error), 429);
if (error instanceof PreviewTimeoutError || deadlineAbort.signal.aborted) {
return c.json({ error: 'Preview exceeded the deadline' }, 504);
}
return c.json(
{ error: error instanceof Error ? error.message : 'Preview rendering failed' },
500,
);
} finally {
clearTimeout(deadline);
release();
}
});
app.get('/render/:jobId', async (c) => {
const job = await jobs.get(c.req.param('jobId'));
if (!job) return c.json({ error: 'Job not found' }, 404);
@@ -283,6 +499,10 @@ async function main(): Promise<void> {
});
coordinator = new RenderCoordinator(executor, jobs, artifacts);
// Build the browser mount off the request path so the first preview does not
// pay the cold esbuild cost while holding admission and execution permits.
await buildSlideClientBundle();
const app = createApp({
jobs,
artifacts,
+60
View File
@@ -0,0 +1,60 @@
export type PreviewRejectionReason =
| 'preview_queue_full'
| 'preview_per_user_limit'
| 'capacity_busy';
/** Thrown when admission control rejects a preview (mapped to HTTP 429). */
export class PreviewRejectedError extends Error {
constructor(
message: string,
readonly reason: PreviewRejectionReason,
) {
super(message);
}
}
/** Independent admission control for synchronous single-page previews. */
export class PreviewGate {
private inFlight = 0;
private readonly activeByIdentity = new Map<string, number>();
constructor(
private readonly maxInFlight: number,
private readonly maxPerUser: number,
) {}
/**
* Claim a slot before the request body is buffered. The returned release
* function is idempotent so every route exit can safely call it once.
*/
acquire(identity: string): () => void {
if (this.inFlight >= this.maxInFlight) {
throw new PreviewRejectedError(
'The preview queue is full; try again shortly.',
'preview_queue_full',
);
}
const active = this.activeByIdentity.get(identity) ?? 0;
if (this.maxPerUser > 0 && active >= this.maxPerUser) {
throw new PreviewRejectedError(
`Too many concurrent previews (limit ${this.maxPerUser}).`,
'preview_per_user_limit',
);
}
this.inFlight += 1;
this.activeByIdentity.set(identity, active + 1);
let released = false;
return () => {
if (released) return;
released = true;
this.inFlight = Math.max(0, this.inFlight - 1);
const remaining = (this.activeByIdentity.get(identity) ?? 0) - 1;
if (remaining <= 0) this.activeByIdentity.delete(identity);
else this.activeByIdentity.set(identity, remaining);
};
}
}
+519
View File
@@ -0,0 +1,519 @@
/** Synchronous single-page preview rendering through Chromium. */
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { build } from 'esbuild';
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
import type {
Action,
InteractiveContent,
PBLContent,
QuizContent,
Scene,
SlideContent,
} from '@openmaic/dsl';
import puppeteer from 'puppeteer-core';
import type { Browser, Frame, Page } from 'puppeteer-core';
export type PreviewScene = Scene<
Action,
SlideContent | QuizContent | InteractiveContent | PBLContent
>;
export interface PreviewStageContext {
id: string;
name: string;
}
export interface PreviewViewport {
width: number;
height: number;
deviceScaleFactor: number;
}
export interface PreviewRequest {
scene: PreviewScene;
stage: PreviewStageContext;
viewport: PreviewViewport;
signal: AbortSignal;
deadlineMs: number;
}
export interface PreviewRenderer {
render(request: PreviewRequest): Promise<Uint8Array>;
}
export class PreviewTimeoutError extends Error {}
const PREVIEW_PROTOCOL_TIMEOUT_BUFFER_MS = 1_000;
const BROWSER_CLOSE_GRACE_MS = 250;
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function htmlDocument(body: string, scene: PreviewScene): string {
return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}</style></head>
<body data-scene-id="${escapeAttribute(scene.id)}">${body}</body></html>`;
}
function isElement(
node: DefaultTreeAdapterTypes.ChildNode,
): node is DefaultTreeAdapterTypes.Element {
return !node.nodeName.startsWith('#');
}
function insertAt(html: string, offset: number, injection: string): string {
return html.slice(0, offset) + injection + html.slice(offset);
}
/** Inject markup first in the parsed head without importing app-side code. */
function injectIntoDocumentHead(html: string, injection: string): string {
const document = parse(html, { sourceCodeLocationInfo: true });
const htmlElement = document.childNodes.find(
(node): node is DefaultTreeAdapterTypes.Element => isElement(node) && node.tagName === 'html',
);
const headElement = htmlElement?.childNodes.find(
(node): node is DefaultTreeAdapterTypes.Element => isElement(node) && node.tagName === 'head',
);
const explicitHeadEnd = headElement?.sourceCodeLocation?.startTag?.endOffset;
if (explicitHeadEnd !== undefined) return insertAt(html, explicitHeadEnd, injection);
const firstHeadChildOffset = headElement?.childNodes.reduce<number | undefined>((first, node) => {
const offset = node.sourceCodeLocation?.startOffset;
if (offset === undefined) return first;
return first === undefined ? offset : Math.min(first, offset);
}, undefined);
if (firstHeadChildOffset !== undefined) return insertAt(html, firstHeadChildOffset, injection);
const explicitHtmlEnd = htmlElement?.sourceCodeLocation?.startTag?.endOffset;
if (explicitHtmlEnd !== undefined) {
return insertAt(html, explicitHtmlEnd, `<head>${injection}</head>`);
}
const doctype = document.childNodes.find((node) => node.nodeName === '#documentType');
const doctypeEnd = doctype?.sourceCodeLocation?.endOffset;
if (doctypeEnd !== undefined) return insertAt(html, doctypeEnd, `<head>${injection}</head>`);
return `<head>${injection}</head>${html}`;
}
/**
* Standalone counterpart of the app's `patchHtmlForIframe` storage behavior.
* The sandbox intentionally omits allow-same-origin, so real Web Storage can
* throw SecurityError; install in-memory stores before authored scripts run.
*/
const STORAGE_SHIM = `<script data-preview-storage-shim>
(function () {
function makeStore() {
var data = Object.create(null);
return {
getItem: function (k) { k = String(k); return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; },
setItem: function (k, v) { data[String(k)] = String(v); },
removeItem: function (k) { delete data[String(k)]; },
clear: function () { data = Object.create(null); },
key: function (i) { var keys = Object.keys(data); return i < keys.length ? keys[i] : null; },
get length() { return Object.keys(data).length; }
};
}
['localStorage', 'sessionStorage'].forEach(function (name) {
var ok = false;
try { var store = window[name]; if (store) { store.getItem('__probe__'); ok = true; } } catch (error) { ok = false; }
if (!ok) {
try { Object.defineProperty(window, name, { value: makeStore(), configurable: true }); } catch (error) {}
}
});
})();
</script>`;
export function injectInteractiveStorageShim(html: string): string {
return injectIntoDocumentHead(html, `\n${STORAGE_SHIM}\n`);
}
function slidePreviewMarkup(
_scene: Extract<PreviewScene, { type: 'slide' }>,
viewport: PreviewViewport,
): string {
return renderToStaticMarkup(
createElement('div', {
id: 'preview-slide-root',
style: { width: `${viewport.width}px`, height: `${viewport.height}px` },
}),
);
}
function interactivePreviewMarkup(
scene: Extract<PreviewScene, { type: 'interactive' }>,
viewport: PreviewViewport,
): string {
if (!scene.content.html) throw new Error('Interactive page has no embedded HTML to preview');
return renderToStaticMarkup(
createElement('iframe', {
title: scene.title,
srcDoc: injectInteractiveStorageShim(scene.content.html),
sandbox: 'allow-scripts allow-forms allow-modals',
style: { width: `${viewport.width}px`, height: `${viewport.height}px`, border: 0 },
}),
);
}
function coverPreviewMarkup(
scene: Extract<PreviewScene, { type: 'quiz' | 'pbl' }>,
stage: PreviewStageContext,
viewport: PreviewViewport,
): string {
const isQuiz = scene.type === 'quiz';
const project = scene.type === 'pbl' ? scene.content.projectV2 : undefined;
const heading = project?.title || scene.title;
const description = project?.description || stage.name;
const count = isQuiz ? scene.content.questions.length : (project?.milestones.length ?? 0);
const countLabel = isQuiz ? `${count} questions` : count > 0 ? `${count} stages` : 'Project';
return renderToStaticMarkup(
createElement(
'main',
{
style: {
width: `${viewport.width}px`,
height: `${viewport.height}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8%',
background: isQuiz
? 'linear-gradient(135deg,#312e81,#2563eb)'
: 'linear-gradient(135deg,#064e3b,#0f766e)',
color: '#fff',
fontFamily: 'Inter, Noto Sans, system-ui, sans-serif',
},
},
createElement(
'section',
{ style: { width: '100%', maxWidth: '900px', textAlign: 'center' } },
createElement(
'div',
{
style: {
display: 'inline-block',
marginBottom: '24px',
padding: '8px 18px',
borderRadius: '999px',
background: 'rgba(255,255,255,.16)',
fontSize: '20px',
},
},
countLabel,
),
createElement('h1', { style: { margin: 0, fontSize: '64px', lineHeight: 1.1 } }, heading),
createElement(
'p',
{ style: { margin: '24px auto 0', fontSize: '26px', opacity: 0.82 } },
description,
),
),
),
);
}
/** Assemble the complete one-scene document inside render-service. */
export function buildPreviewHtml(
scene: PreviewScene,
stage: PreviewStageContext,
viewport: PreviewViewport,
): string {
const markup =
scene.type === 'slide'
? slidePreviewMarkup(scene, viewport)
: scene.type === 'interactive'
? interactivePreviewMarkup(scene, viewport)
: coverPreviewMarkup(scene, stage, viewport);
return htmlDocument(markup, scene);
}
let slideClientBundle: Promise<string> | undefined;
type SlideBundleBuilder = typeof build;
/** Bundle the browser-only SlideCanvas mount once per service process. */
export function buildSlideClientBundle(builder: SlideBundleBuilder = build): Promise<string> {
if (slideClientBundle) return slideClientBundle;
const candidate = builder({
stdin: {
sourcefile: 'preview-slide-client.js',
resolveDir: dirname(fileURLToPath(import.meta.url)),
contents: `
import React from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { SlideCanvas } from '@openmaic/renderer';
const props = window.__OPENMAIC_PREVIEW_PROPS__;
const root = document.getElementById('preview-slide-root');
if (!props || !root) throw new Error('Preview slide mount data is missing');
const canvas = props.slide;
const nativeWidth = canvas.viewportSize || 1000;
const nativeHeight = nativeWidth * (canvas.viewportRatio || 0.5625);
const scale = Math.min(props.viewport.width / nativeWidth, props.viewport.height / nativeHeight);
const renderedWidth = nativeWidth * scale;
const renderedHeight = nativeHeight * scale;
const canvasNode = React.createElement(SlideCanvas, {
slide: canvas,
scale,
chrome: false,
style: { width: renderedWidth + 'px', height: renderedHeight + 'px' },
});
const frame = React.createElement('main', {
style: {
width: props.viewport.width + 'px',
height: props.viewport.height + 'px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: '#fff',
},
}, React.createElement('div', {
style: { width: renderedWidth + 'px', height: renderedHeight + 'px' },
}, canvasNode));
flushSync(() => createRoot(root).render(frame));
window.__OPENMAIC_PREVIEW_MOUNTED__ = true;
`,
},
bundle: true,
format: 'iife',
platform: 'browser',
target: 'chrome120',
write: false,
}).then((result) => {
const output = result.outputFiles[0];
if (!output) throw new Error('Failed to build the preview slide client');
return output.text;
});
const retryable = candidate.catch((error: unknown) => {
if (slideClientBundle === retryable) slideClientBundle = undefined;
throw error;
});
slideClientBundle = retryable;
return slideClientBundle;
}
type AssetDocument = Page | Frame;
/** Wait for fonts, images, client effects, and layout mutations to settle. */
export async function waitForDocumentAssets(document: AssetDocument): Promise<void> {
await document.evaluate(async () => {
await globalThis.document.fonts?.ready.catch(() => undefined);
await Promise.all(
Array.from(globalThis.document.images, (image) =>
image.complete
? Promise.resolve()
: new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
setTimeout(resolve, 2_000);
}),
),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
await new Promise<void>((resolve) => {
let quietTimer = setTimeout(done, 100);
const maximumTimer = setTimeout(done, 2_000);
const observer = new MutationObserver(() => {
clearTimeout(quietTimer);
quietTimer = setTimeout(done, 100);
});
function done() {
clearTimeout(quietTimer);
clearTimeout(maximumTimer);
observer.disconnect();
resolve();
}
observer.observe(globalThis.document.documentElement, {
attributes: true,
childList: true,
subtree: true,
});
});
});
}
/** Wait for a srcDoc iframe and the assets inside its browsing context. */
export async function waitForInteractiveFrame(page: Page): Promise<void> {
const iframe = await page.waitForSelector('iframe');
const frame = await iframe?.contentFrame();
if (!frame) throw new Error('Interactive preview iframe did not load');
await frame.waitForFunction(
() => location.href === 'about:srcdoc' && document.readyState === 'complete',
);
await waitForDocumentAssets(frame);
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('Preview aborted');
}
async function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) throw abortError(signal);
let rejectAbort!: (error: Error) => void;
const aborted = new Promise<never>((_resolve, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort(abortError(signal));
signal.addEventListener('abort', onAbort, { once: true });
try {
return await Promise.race([operation, aborted]);
} finally {
signal.removeEventListener('abort', onAbort);
}
}
function forceKillBrowser(browser: Browser): void {
try {
browser.process()?.kill('SIGKILL');
} catch {
// The process may already have exited; bounded close below remains best effort.
}
}
export async function closeBrowserBounded(browser: Browser): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
await Promise.race([
browser.close().catch(() => {}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
timedOut = true;
resolve();
}, BROWSER_CLOSE_GRACE_MS);
}),
]);
if (timeout) clearTimeout(timeout);
if (timedOut) forceKillBrowser(browser);
}
async function launchWithAbort(launch: Promise<Browser>, signal: AbortSignal): Promise<Browser> {
try {
return await raceWithAbort(launch, signal);
} catch (error) {
if (signal.aborted) {
void launch
.then(async (browser) => {
forceKillBrowser(browser);
await closeBrowserBounded(browser);
})
.catch(() => {});
}
throw error;
}
}
/** Mount the slide client while surfacing synchronous in-page crashes immediately. */
export async function mountSlideClient(page: Page, bundle: string): Promise<void> {
let rejectPageError!: (error: Error) => void;
const pageError = new Promise<never>((_resolve, reject) => {
rejectPageError = reject;
});
const onPageError = (error: unknown) =>
rejectPageError(error instanceof Error ? error : new Error(String(error)));
page.on('pageerror', onPageError);
try {
await Promise.race([
(async () => {
await page.addScriptTag({ content: bundle });
await page.waitForFunction(() => '__OPENMAIC_PREVIEW_MOUNTED__' in window);
})(),
pageError,
]);
} finally {
page.off('pageerror', onPageError);
}
}
export interface ChromiumPreviewRendererOptions {
browserLauncher?: Pick<typeof puppeteer, 'launch'>;
}
export class ChromiumPreviewRenderer implements PreviewRenderer {
private readonly browserLauncher: Pick<typeof puppeteer, 'launch'>;
constructor(options: ChromiumPreviewRendererOptions = {}) {
this.browserLauncher = options.browserLauncher ?? puppeteer;
}
async render(request: PreviewRequest): Promise<Uint8Array> {
if (request.signal.aborted) throw abortError(request.signal);
const executablePath =
process.env.PRODUCER_HEADLESS_SHELL_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
if (!executablePath) {
throw new Error('Chromium executable is not configured for preview rendering');
}
const browser = await launchWithAbort(
this.browserLauncher.launch({
executablePath,
headless: true,
protocolTimeout: request.deadlineMs + PREVIEW_PROTOCOL_TIMEOUT_BUFFER_MS,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
}),
request.signal,
);
const closeOnAbort = () => forceKillBrowser(browser);
request.signal.addEventListener('abort', closeOnAbort, { once: true });
try {
return await raceWithAbort(
(async () => {
const page = await browser.newPage();
await page.setViewport(request.viewport);
await page.setContent(buildPreviewHtml(request.scene, request.stage, request.viewport), {
waitUntil: 'domcontentloaded',
});
if (request.scene.type === 'slide') {
await page.evaluate(
(slide, viewport) => {
Object.assign(window, {
__OPENMAIC_PREVIEW_PROPS__: { slide, viewport },
});
},
request.scene.content.canvas,
request.viewport,
);
await mountSlideClient(page, await buildSlideClientBundle());
}
const selected = await page.evaluate(
(sceneId) => document.body.getAttribute('data-scene-id') === sceneId,
request.scene.id,
);
if (!selected) {
throw new Error(
`Requested scene was not found in the preview page (${request.scene.id})`,
);
}
if (request.scene.type === 'interactive') await waitForInteractiveFrame(page);
else await waitForDocumentAssets(page);
const png = await page.screenshot({ type: 'png', optimizeForSpeed: true });
return new Uint8Array(png);
})(),
request.signal,
);
} catch (error) {
if (request.signal.aborted) throw abortError(request.signal);
throw error;
} finally {
request.signal.removeEventListener('abort', closeOnAbort);
await closeBrowserBounded(browser);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
/** Pure semantic checks that keep previews faithful and drawable. */
import { slideMediaSlotDescriptors } from '@openmaic/dsl';
import { parse } from 'parse5';
import type { PreviewScene } from './preview-renderer.js';
interface HtmlNode {
tagName?: string;
attrs?: Array<{ name: string; value: string }>;
childNodes?: HtmlNode[];
content?: HtmlNode;
value?: string;
}
interface CanvasElementShape {
type: string;
}
export const MAX_INTERACTIVE_HTML_DEPTH = 256;
export const MAX_INTERACTIVE_HTML_ELEMENTS = 10_000;
class InteractiveHtmlLimitError extends Error {}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isCanvasElementShape(value: unknown): value is CanvasElementShape {
return isRecord(value) && typeof value.type === 'string' && value.type.trim().length > 0;
}
/** Return a structural scene error the upstream DSL validator does not yet catch. */
export function invalidSlideCanvasElementError(scene: unknown): string | undefined {
if (!isRecord(scene) || scene.type !== 'slide') return undefined;
const content = scene.content;
if (!isRecord(content)) return undefined;
const canvas = content.canvas;
if (!isRecord(canvas) || !Array.isArray(canvas.elements)) return undefined;
const invalidIndex = canvas.elements.findIndex((element) => !isCanvasElementShape(element));
return invalidIndex === -1
? undefined
: `Invalid scene at /content/canvas/elements/${invalidIndex}: canvas element must be an object with a non-empty type`;
}
function isDataUrl(value: string | undefined): boolean {
return /^data:/i.test(value?.trim() ?? '');
}
function isSelfContainedCssUrl(value: string): boolean {
const trimmed = value.trim();
return isDataUrl(trimmed) || trimmed.startsWith('#');
}
/** Count slide media slots that cannot load inside the isolated preview page. */
export function countNonSelfContainedSlideMediaReferences(
scene: Extract<PreviewScene, { type: 'slide' }>,
): number {
// previewabilityError reports malformed entries. Keep this exported counter
// total and non-throwing as defense in depth for direct callers.
if (
!Array.isArray(scene.content.canvas.elements) ||
!scene.content.canvas.elements.every(isCanvasElementShape)
) {
return 0;
}
let count = 0;
for (const slot of slideMediaSlotDescriptors(scene.content.canvas)) {
if (slot.ref === undefined) continue;
if (slot.kind === 'video-media-ref' && slot.elementIndex !== undefined) {
const element = scene.content.canvas.elements[slot.elementIndex];
if (element?.type === 'video' && isDataUrl(element.src)) continue;
}
if (!isDataUrl(slot.ref)) count += 1;
}
return count;
}
const RESOURCE_TAGS = new Set([
'script',
'img',
'video',
'audio',
'source',
'iframe',
'embed',
'object',
]);
const RESOURCE_ATTRIBUTES = new Set(['src', 'href', 'srcset', 'poster']);
const RESOURCE_LINK_RELS = new Set(['stylesheet', 'preload', 'modulepreload', 'prefetch']);
const CSS_URL_PATTERN = /url\(\s*(['"]?)(.*?)\1\s*\)/gis;
function attributes(node: HtmlNode): Map<string, string> {
return new Map(node.attrs?.map((attribute) => [attribute.name.toLowerCase(), attribute.value]));
}
function linkLoadsResource(attrs: Map<string, string>): boolean {
const rels = (attrs.get('rel') ?? '').toLowerCase().split(/\s+/).filter(Boolean);
return rels.some(
(rel) => RESOURCE_LINK_RELS.has(rel) || rel.includes('icon') || rel.includes('font'),
);
}
/** Parse the URLs in a srcset without splitting the comma inside a data URL. */
function srcsetUrls(srcset: string): string[] {
const urls: string[] = [];
let position = 0;
while (position < srcset.length) {
while (position < srcset.length && /[\s,]/.test(srcset[position] ?? '')) position += 1;
if (position >= srcset.length) break;
const start = position;
while (position < srcset.length && !/\s/.test(srcset[position] ?? '')) position += 1;
let url = srcset.slice(start, position);
if (url.endsWith(',')) {
url = url.replace(/,+$/, '');
if (url) urls.push(url);
continue;
}
while (position < srcset.length && srcset[position] !== ',') position += 1;
if (srcset[position] === ',') position += 1;
if (url) urls.push(url);
}
return urls;
}
function cssUrls(css: string): string[] {
return Array.from(css.matchAll(CSS_URL_PATTERN), (match) => (match[2] ?? '').trim());
}
/** Find interactive resource references that are neither inline nor data URLs. */
export function findNonSelfContainedInteractiveReferences(html: string): string[] {
const rejected: string[] = [];
const rejectUnlessData = (value: string) => {
if (!isDataUrl(value)) rejected.push(value.trim());
};
const rejectUnlessSelfContainedCssUrl = (value: string) => {
if (!isSelfContainedCssUrl(value)) rejected.push(value.trim());
};
const visit = (node: HtmlNode): void => {
const tagName = node.tagName?.toLowerCase();
const attrs = attributes(node);
const style = attrs.get('style');
if (style !== undefined) cssUrls(style).forEach(rejectUnlessSelfContainedCssUrl);
if (tagName === 'style') {
for (const child of node.childNodes ?? []) {
if (typeof child.value === 'string') {
cssUrls(child.value).forEach(rejectUnlessSelfContainedCssUrl);
}
}
} else if (tagName === 'link') {
const href = attrs.get('href');
if (href !== undefined && linkLoadsResource(attrs)) rejectUnlessData(href);
} else if (tagName && RESOURCE_TAGS.has(tagName)) {
for (const [name, value] of attrs) {
if (!RESOURCE_ATTRIBUTES.has(name)) continue;
if (name === 'srcset') srcsetUrls(value).forEach(rejectUnlessData);
else rejectUnlessData(value);
}
}
};
const root = parse(html, { scriptingEnabled: true }) as unknown as HtmlNode;
const pending: Array<{ node: HtmlNode; ancestorElementDepth: number }> = [
{ node: root, ancestorElementDepth: 0 },
];
let elementCount = 0;
while (pending.length > 0) {
const current = pending.pop();
if (!current) break;
const isElement = current.node.tagName !== undefined;
const elementDepth = current.ancestorElementDepth + (isElement ? 1 : 0);
if (isElement) {
if (elementDepth > MAX_INTERACTIVE_HTML_DEPTH) {
throw new InteractiveHtmlLimitError(
`Interactive HTML exceeds the maximum DOM depth of ${MAX_INTERACTIVE_HTML_DEPTH}`,
);
}
elementCount += 1;
if (elementCount > MAX_INTERACTIVE_HTML_ELEMENTS) {
throw new InteractiveHtmlLimitError(
`Interactive HTML exceeds the maximum element count of ${MAX_INTERACTIVE_HTML_ELEMENTS}`,
);
}
}
visit(current.node);
// Preserve the recursive walk's child-before-template-content order without
// spreading an adversarially wide child list into function arguments.
if (current.node.content) {
pending.push({ node: current.node.content, ancestorElementDepth: elementDepth });
}
const children = current.node.childNodes ?? [];
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (child) pending.push({ node: child, ancestorElementDepth: elementDepth });
}
}
return rejected;
}
/** Return an actionable 422 message when a valid scene cannot be faithfully previewed. */
export function previewabilityError(scene: PreviewScene): string | undefined {
if (scene.type === 'slide') {
const invalidElement = invalidSlideCanvasElementError(scene);
if (invalidElement) return invalidElement;
if (
!Array.isArray(scene.content.canvas.elements) ||
(scene.content.canvas.elements.length === 0 && !scene.content.canvas.background)
) {
return 'Slide canvas has no renderable elements';
}
const rejected = countNonSelfContainedSlideMediaReferences(scene);
if (rejected > 0) {
return `Scene is not self-contained: ${rejected} slide media reference(s) must use data: URLs`;
}
}
if (scene.type === 'interactive') {
const html = scene.content.html;
if (typeof html !== 'string' || !html.trim()) {
return 'Interactive scene requires non-empty embedded HTML for previewing';
}
let rejected: string[];
try {
rejected = findNonSelfContainedInteractiveReferences(html);
} catch (error) {
if (error instanceof InteractiveHtmlLimitError) return error.message;
throw error;
}
if (rejected.length > 0) {
return `Interactive HTML is not self-contained: ${rejected.length} resource reference(s) must be inline or use data: URLs`;
}
}
return undefined;
}
+51 -19
View File
@@ -13,6 +13,7 @@ import type { ArtifactStore } from './artifact-store.js';
import { config } from './config.js';
import type { JobStore } from './job-store.js';
import type { RenderExecutor } from './render-executor.js';
import { Semaphore } from './semaphore.js';
import type {
RenderCancelledFailure,
RenderExecutionResult,
@@ -79,6 +80,7 @@ export class RenderCoordinator {
private readonly maxQueue: number;
private readonly maxJobsPerUser: number;
private readonly jobDeadlineMs: number;
private readonly executionGate: Semaphore;
constructor(
private readonly executor: RenderExecutor,
@@ -90,6 +92,27 @@ export class RenderCoordinator {
this.maxQueue = options.maxQueue ?? config.maxQueue;
this.maxJobsPerUser = options.maxJobsPerUser ?? config.maxJobsPerUser;
this.jobDeadlineMs = options.jobDeadlineMs ?? config.jobDeadlineMs;
this.executionGate = new Semaphore(this.maxConcurrency);
}
/** Run Chromium-backed work within the service-wide execution budget. */
runWithExecutionSlot<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
return this.executionGate.run(task, signal);
}
/** Run work only when an execution slot is available now; never queue. */
tryRunWithExecutionSlot<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> | undefined {
signal?.throwIfAborted();
const release = this.executionGate.tryAcquire();
if (!release) return undefined;
return (async () => {
try {
signal?.throwIfAborted();
return await task();
} finally {
release();
}
})();
}
/** Total jobs occupying the system: reserved + queued + running. */
@@ -239,25 +262,27 @@ export class RenderCoordinator {
const { id, projectDir } = record;
const outputPath = join(projectDir, 'output.mp4');
try {
await this.jobs.update(id, { status: 'running', currentStage: 'preparing' });
const result = await this.executor.execute({
projectDir,
outputPath,
options,
signal: abort.signal,
deadlineMs: this.jobDeadlineMs,
onProgress: async (progress) => {
await this.jobs.update(id, {
status: 'running',
progress: progress.progress,
currentStage: progress.stage,
...(progress.framesRendered !== undefined
? { framesRendered: progress.framesRendered }
: {}),
...(progress.totalFrames !== undefined ? { totalFrames: progress.totalFrames } : {}),
});
},
});
const result = await this.runWithExecutionSlot(async () => {
await this.jobs.update(id, { status: 'running', currentStage: 'preparing' });
return this.executor.execute({
projectDir,
outputPath,
options,
signal: abort.signal,
deadlineMs: this.jobDeadlineMs,
onProgress: async (progress) => {
await this.jobs.update(id, {
status: 'running',
progress: progress.progress,
currentStage: progress.stage,
...(progress.framesRendered !== undefined
? { framesRendered: progress.framesRendered }
: {}),
...(progress.totalFrames !== undefined ? { totalFrames: progress.totalFrames } : {}),
});
},
});
}, abort.signal);
if (result.status !== 'succeeded') {
await this.finishNonSuccess(id, projectDir, result);
@@ -285,6 +310,13 @@ export class RenderCoordinator {
});
} catch (error) {
await this.artifacts.remove(id).catch(() => {});
if (abort.signal.aborted) {
await this.finishNonSuccess(id, projectDir, {
status: 'cancelled',
failure: { code: 'cancelled', message: 'Render cancelled' },
});
return;
}
const failure: RenderFailedFailure = {
code: 'execution_failed',
message: error instanceof Error ? error.message : String(error),
+6
View File
@@ -15,6 +15,8 @@ export interface ResourceProfile {
producerWorkers: 1;
maxConcurrency: 1;
maxConcurrentExtractions: 1;
maxPreviewPixels: number;
maxPreviewDeviceScaleFactor: number;
/** Hard local chunk fan-out limits for the selected memory/CPU profile. */
maxChunkWorkers: number;
maxParallelChunks: number;
@@ -42,6 +44,8 @@ function defineProfile(
// screenshot for compatibility-sensitive compositions such as iframe GenUI.
requireBeginFrame: false,
...COMMON_LIMITS,
maxPreviewPixels: name === 'low-memory' ? 1920 * 1080 : 3840 * 2160,
maxPreviewDeviceScaleFactor: name === 'low-memory' ? 1 : 2,
minimumMemoryBytes,
maxChunkWorkers: 1,
maxParallelChunks,
@@ -170,6 +174,8 @@ export function publicResourceProfile(profile: ResourceProfile) {
producerWorkers: profile.producerWorkers,
maxConcurrency: profile.maxConcurrency,
maxConcurrentExtractions: profile.maxConcurrentExtractions,
maxPreviewPixels: profile.maxPreviewPixels,
maxPreviewDeviceScaleFactor: profile.maxPreviewDeviceScaleFactor,
maxChunkWorkers: profile.maxChunkWorkers,
maxParallelChunks: profile.maxParallelChunks,
minimumMemoryMiB: profile.minimumMemoryBytes / 1024 ** 2,
+44 -7
View File
@@ -8,14 +8,19 @@
*/
export class Semaphore {
private available: number;
private readonly waiters: Array<() => void> = [];
private readonly waiters: Array<{
resolve: () => void;
reject: (reason: unknown) => void;
signal?: AbortSignal;
onAbort?: () => void;
}> = [];
constructor(permits: number) {
this.available = Math.max(1, permits);
}
async run<T>(task: () => Promise<T>): Promise<T> {
await this.acquire();
async run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
await this.acquire(signal);
try {
return await task();
} finally {
@@ -23,17 +28,49 @@ export class Semaphore {
}
}
private acquire(): Promise<void> {
/**
* Claim an immediately available permit without joining the FIFO queue.
* Returns an idempotent release function, or undefined when capacity is busy.
*/
tryAcquire(): (() => void) | undefined {
if (this.available <= 0) return undefined;
this.available -= 1;
let released = false;
return () => {
if (released) return;
released = true;
this.release();
};
}
private acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('Operation aborted'));
if (this.available > 0) {
this.available -= 1;
return Promise.resolve();
}
return new Promise((resolve) => this.waiters.push(resolve));
return new Promise((resolve, reject) => {
const waiter = { resolve, reject, signal, onAbort: undefined as (() => void) | undefined };
if (signal) {
waiter.onAbort = () => {
const index = this.waiters.indexOf(waiter);
if (index < 0) return;
this.waiters.splice(index, 1);
reject(signal.reason ?? new Error('Operation aborted'));
};
signal.addEventListener('abort', waiter.onAbort, { once: true });
}
this.waiters.push(waiter);
});
}
private release(): void {
const next = this.waiters.shift();
if (next) next();
else this.available += 1;
if (next) {
if (next.signal && next.onAbort) {
next.signal.removeEventListener('abort', next.onAbort);
}
next.resolve();
} else this.available += 1;
}
}
+26
View File
@@ -18,6 +18,10 @@ const KEYS = [
'PRODUCER_BROWSER_GPU_MODE',
'PRODUCER_ENABLE_BROWSER_POOL',
'RENDER_REQUIRE_BEGINFRAME',
'RENDER_PREVIEW_TIMEOUT_MS',
'RENDER_PREVIEW_MAX_IN_FLIGHT',
'RENDER_PREVIEW_MAX_PER_USER',
'RENDER_PREVIEW_MAX_JSON_BYTES',
] as const;
const originals = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
@@ -60,6 +64,28 @@ describe('config maxJobsPerUser', () => {
});
});
describe('config preview admission', () => {
it('provides bounded defaults', async () => {
const config = await loadConfig();
expect(config.previewDeadlineMs).toBe(20_000);
expect(config.previewMaxInFlight).toBe(8);
expect(config.previewMaxPerUser).toBe(2);
expect(config.previewMaxJsonBytes).toBe(32 * 1024 * 1024);
});
it('accepts explicit overrides and zero to disable the per-user cap', async () => {
process.env.RENDER_PREVIEW_TIMEOUT_MS = '15000';
process.env.RENDER_PREVIEW_MAX_IN_FLIGHT = '4';
process.env.RENDER_PREVIEW_MAX_PER_USER = '0';
process.env.RENDER_PREVIEW_MAX_JSON_BYTES = '1048576';
const config = await loadConfig();
expect(config.previewDeadlineMs).toBe(15_000);
expect(config.previewMaxInFlight).toBe(4);
expect(config.previewMaxPerUser).toBe(0);
expect(config.previewMaxJsonBytes).toBe(1_048_576);
});
});
describe('config producerWorkers', () => {
it('defaults to one explicit worker in the standard profile', async () => {
delete process.env.PRODUCER_MAX_WORKERS;
@@ -24,6 +24,9 @@ describe('render-service container contract', () => {
expect(compose).toContain('PRODUCER_HEADLESS_SHELL_PATH=/usr/bin/chromium-headless-shell');
expect(compose).toContain('PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS=900000');
expect(compose).toContain('HF_STATIC_DEDUP=false');
expect(compose).toContain('RENDER_PREVIEW_TIMEOUT_MS=${RENDER_PREVIEW_TIMEOUT_MS:-20000}');
expect(compose).toContain('RENDER_PREVIEW_MAX_IN_FLIGHT=${RENDER_PREVIEW_MAX_IN_FLIGHT:-8}');
expect(compose).toContain('RENDER_PREVIEW_MAX_PER_USER=${RENDER_PREVIEW_MAX_PER_USER:-2}');
expect(compose).toContain('mem_limit: ${RENDER_SERVICE_MEMORY_LIMIT:-8g}');
});
@@ -52,4 +55,30 @@ describe('render-service container contract', () => {
const packageJson = read('package.json');
expect(packageJson).toContain('"@hyperframes/producer": "^0.7.107"');
});
it('prewarms the slide bundle before the server starts accepting requests', () => {
const main = read('src/main.ts');
expect(main.indexOf('await buildSlideClientBundle()')).toBeGreaterThan(0);
expect(main.indexOf('await buildSlideClientBundle()')).toBeLessThan(
main.indexOf('serve({ fetch: app.fetch'),
);
});
it('documents the complete preview API and admission contract', () => {
const readme = read('README.md');
for (const expected of [
'POST /preview',
'RENDER_PREVIEW_TIMEOUT_MS',
'RENDER_PREVIEW_MAX_IN_FLIGHT',
'RENDER_PREVIEW_MAX_PER_USER',
'RENDER_PREVIEW_MAX_JSON_BYTES',
'preview_queue_full',
'preview_per_user_limit',
'capacity_busy',
'reflects the render queue only',
'requires fully self-contained scenes',
]) {
expect(readme).toContain(expected);
}
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { PreviewGate, PreviewRejectedError } from '../src/preview-gate.js';
describe('PreviewGate', () => {
it('enforces the global in-flight cap', () => {
const gate = new PreviewGate(1, 0);
const release = gate.acquire('alice');
expect(() => gate.acquire('bob')).toThrow(PreviewRejectedError);
try {
gate.acquire('bob');
} catch (error) {
expect(error).toMatchObject({
message: expect.stringMatching(/preview queue is full/i),
reason: 'preview_queue_full',
});
}
release();
expect(() => gate.acquire('bob')).not.toThrow();
});
it('enforces an independent per-identity cap', () => {
const gate = new PreviewGate(8, 1);
const releaseAlice = gate.acquire('alice');
try {
gate.acquire('alice');
} catch (error) {
expect(error).toMatchObject({
message: expect.stringMatching(/Too many concurrent previews \(limit 1\)/),
reason: 'preview_per_user_limit',
});
}
const releaseBob = gate.acquire('bob');
expect(() => gate.acquire('bob')).toThrow(PreviewRejectedError);
releaseAlice();
releaseBob();
});
it('disables the per-identity check when maxPerUser is zero', () => {
const gate = new PreviewGate(3, 0);
const releases = [gate.acquire('alice'), gate.acquire('alice'), gate.acquire('alice')];
expect(() => gate.acquire('bob')).toThrow(/preview queue is full/i);
releases.forEach((release) => release());
});
it('returns an idempotent release function', () => {
const gate = new PreviewGate(2, 1);
const release = gate.acquire('alice');
release();
release();
const current = gate.acquire('alice');
expect(() => gate.acquire('alice')).toThrow(PreviewRejectedError);
current();
expect(() => gate.acquire('alice')).not.toThrow();
});
});
@@ -0,0 +1,216 @@
import { runInNewContext } from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Browser, Frame, Page } from 'puppeteer-core';
import {
ChromiumPreviewRenderer,
PreviewTimeoutError,
buildSlideClientBundle,
buildPreviewHtml,
closeBrowserBounded,
injectInteractiveStorageShim,
mountSlideClient,
type PreviewScene,
waitForInteractiveFrame,
} from '../src/preview-renderer.js';
const originalExecutable = process.env.PRODUCER_HEADLESS_SHELL_PATH;
afterEach(() => {
vi.useRealTimers();
if (originalExecutable === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalExecutable;
});
const viewport = { width: 1280, height: 720, deviceScaleFactor: 1 };
function chartScene(): PreviewScene {
return {
id: 'chart-scene',
stageId: 'stage-1',
order: 1,
title: 'Chart preview',
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'canvas-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#fff',
themeColors: ['#2563eb'],
fontColor: '#111',
fontName: 'Inter',
},
elements: [
{
id: 'chart-1',
type: 'chart',
left: 100,
top: 100,
width: 500,
height: 300,
rotate: 0,
chartType: 'bar',
data: { labels: ['A'], legends: ['Series'], series: [[1]] },
},
],
},
},
actions: [],
} as unknown as PreviewScene;
}
describe('preview renderer browser readiness', () => {
it('uses an empty browser mount root instead of server-rendering SlideCanvas', () => {
const html = buildPreviewHtml(chartScene(), { id: 'stage-1', name: 'Charts' }, viewport);
expect(html).toContain('id="preview-slide-root"');
expect(html).not.toContain('class="chart"');
expect(html).not.toContain('slide-element-chart-1');
});
it('waits for the interactive frame to complete and settles its nested assets', async () => {
const evaluate = vi.fn(async () => undefined);
const waitForFunction = vi.fn(async () => undefined);
const frame = { evaluate, waitForFunction } as unknown as Frame;
const contentFrame = vi.fn(async () => frame);
const waitForSelector = vi.fn(async () => ({ contentFrame }));
const page = { waitForSelector } as unknown as Page;
await waitForInteractiveFrame(page);
expect(waitForSelector).toHaveBeenCalledWith('iframe');
expect(contentFrame).toHaveBeenCalledOnce();
expect(waitForFunction).toHaveBeenCalledOnce();
expect(waitForFunction.mock.calls[0]?.[0].toString()).toContain('about:srcdoc');
expect(evaluate).toHaveBeenCalledOnce();
expect(evaluate.mock.calls[0]?.[0].toString()).toContain('document.images');
expect(evaluate.mock.calls[0]?.[0].toString()).toContain('document.fonts');
});
it('retries the slide-client build after a rejected memoized promise', async () => {
const failedBuild = vi.fn(async () => {
throw new Error('transient esbuild failure');
});
await expect(buildSlideClientBundle(failedBuild as never)).rejects.toThrow(
'transient esbuild failure',
);
const successfulBuild = vi.fn(async () => ({ outputFiles: [{ text: 'ready bundle' }] }));
await expect(buildSlideClientBundle(successfulBuild as never)).resolves.toBe('ready bundle');
expect(failedBuild).toHaveBeenCalledOnce();
expect(successfulBuild).toHaveBeenCalledOnce();
});
it('fails a slide mount immediately when the page reports an in-page crash', async () => {
let pageErrorHandler: ((error: Error) => void) | undefined;
const page = {
on: vi.fn((_event: string, handler: (error: Error) => void) => {
pageErrorHandler = handler;
}),
off: vi.fn(() => {
pageErrorHandler = undefined;
}),
addScriptTag: vi.fn(async () => {
pageErrorHandler?.(new Error('SlideCanvas mount crashed'));
}),
waitForFunction: vi.fn(() => new Promise<void>(() => {})),
} as unknown as Page;
await expect(mountSlideClient(page, 'broken bundle')).rejects.toThrow(
'SlideCanvas mount crashed',
);
expect(page.on).toHaveBeenCalledWith('pageerror', expect.any(Function));
expect(page.off).toHaveBeenCalledWith('pageerror', expect.any(Function));
});
it('installs usable in-memory storage before authored interactive scripts', () => {
const patched = injectInteractiveStorageShim(
'<!doctype html><html><head><script>localStorage.setItem("answer", 42); window.result = localStorage.getItem("answer");</script></head></html>',
);
const scripts = [...patched.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(
(match) => match[1],
);
expect(patched.indexOf('data-preview-storage-shim')).toBeLessThan(
patched.indexOf('localStorage.setItem'),
);
const context: Record<string, unknown> = {};
context.window = context;
Object.defineProperty(context, 'localStorage', {
configurable: true,
get() {
throw new Error('SecurityError');
},
});
Object.defineProperty(context, 'sessionStorage', {
configurable: true,
get() {
throw new Error('SecurityError');
},
});
for (const script of scripts) runInNewContext(script, context);
expect(context.result).toBe('42');
});
it('bounds protocol calls and force-kills Chromium when the preview aborts', async () => {
process.env.PRODUCER_HEADLESS_SHELL_PATH = '/test/chromium-headless-shell';
const kill = vi.fn();
const close = vi.fn(() => new Promise<void>(() => {}));
const newPage = vi.fn(() => new Promise<Page>(() => {}));
const browser = {
newPage,
close,
process: () => ({ kill }),
} as unknown as Browser;
const launch = vi.fn(async () => browser);
const renderer = new ChromiumPreviewRenderer({
browserLauncher: { launch } as never,
});
const abort = new AbortController();
const rendered = renderer.render({
scene: {
id: 'interactive-1',
stageId: 'stage-1',
order: 1,
title: 'Widget',
type: 'interactive',
content: { type: 'interactive', html: '<!doctype html><p>Ready</p>' },
actions: [],
},
stage: { id: 'stage-1', name: 'Course' },
viewport,
signal: abort.signal,
deadlineMs: 40,
});
await vi.waitFor(() => expect(newPage).toHaveBeenCalledOnce());
const started = Date.now();
abort.abort(new PreviewTimeoutError('Preview exceeded the deadline'));
await expect(rendered).rejects.toThrow(PreviewTimeoutError);
expect(Date.now() - started).toBeLessThan(800);
expect(launch.mock.calls[0]?.[0]).toMatchObject({ protocolTimeout: 1_040 });
expect(kill).toHaveBeenCalledWith('SIGKILL');
expect(close).toHaveBeenCalledOnce();
});
it('force-kills Chromium when the bounded browser close times out', async () => {
vi.useFakeTimers();
const kill = vi.fn();
const close = vi.fn(() => new Promise<void>(() => {}));
const browser = { close, process: () => ({ kill }) } as unknown as Browser;
const closing = closeBrowserBounded(browser);
expect(close).toHaveBeenCalledOnce();
expect(kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(250);
await closing;
expect(kill).toHaveBeenCalledOnce();
expect(kill).toHaveBeenCalledWith('SIGKILL');
});
});
+594
View File
@@ -0,0 +1,594 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { PreviewGate } from '../src/preview-gate.js';
import { PreviewTimeoutError, type PreviewRenderer } from '../src/preview-renderer.js';
import {
MAX_INTERACTIVE_HTML_DEPTH,
MAX_INTERACTIVE_HTML_ELEMENTS,
} from '../src/preview-validation.js';
import type { RenderExecutor } from '../src/render-executor.js';
import { Semaphore } from '../src/semaphore.js';
import {
createMemoryArtifactStore,
createMemoryJobStore,
succeedingExecutor,
} from './support/fakes.js';
process.env.RENDER_SERVICE_NO_LISTEN = 'true';
let createApp: typeof import('../src/main.js').createApp;
let RenderCoordinator: typeof import('../src/render-coordinator.js').RenderCoordinator;
beforeAll(async () => {
({ createApp } = await import('../src/main.js'));
({ RenderCoordinator } = await import('../src/render-coordinator.js'));
});
function previewPayload() {
return {
version: 1,
scene: {
id: 'scene-1',
stageId: 'stage-1',
order: 1,
title: 'Preview me',
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'canvas-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#fff',
themeColors: ['#000'],
fontColor: '#111',
fontName: 'Inter',
},
elements: [{ id: 'text-1', type: 'text', content: 'Preview' }],
},
},
actions: [],
},
stage: { id: 'stage-1', name: 'Preview course' },
viewport: { width: 1280, height: 720, deviceScaleFactor: 1 },
} as const;
}
function previewRequest(payload: unknown = previewPayload(), identity = 'preview-user'): Request {
return new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-openmaic-client': identity },
body: JSON.stringify(payload),
});
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
async function waitFor(check: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (check()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error('Timed out waiting for condition');
}
function appWith(
previewRenderer: PreviewRenderer,
previewGate = new PreviewGate(8, 2),
options: {
extractionGate?: Semaphore;
previewDeadlineMs?: number;
previewMaxJsonBytes?: number;
} = {},
) {
const jobs = createMemoryJobStore();
const artifacts = createMemoryArtifactStore().store;
const coordinator = new RenderCoordinator(succeedingExecutor, jobs, artifacts);
return createApp({
jobs,
artifacts,
coordinator,
extractionGate: options.extractionGate ?? new Semaphore(1),
previewGate,
previewRenderer,
previewDeadlineMs: options.previewDeadlineMs,
previewMaxJsonBytes: options.previewMaxJsonBytes,
});
}
describe('POST /preview', () => {
it('returns the rendered PNG synchronously', async () => {
const render = vi.fn<PreviewRenderer['render']>(async () => new Uint8Array([137, 80, 78, 71]));
const response = await appWith({ render }).fetch(previewRequest());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/png');
expect(response.headers.get('content-length')).toBe('4');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(render).toHaveBeenCalledWith(
expect.objectContaining({
scene: previewPayload().scene,
stage: previewPayload().stage,
viewport: previewPayload().viewport,
signal: expect.any(AbortSignal),
}),
);
});
it.each([
{
name: 'non-JSON content',
request: new Request('http://test/preview', { method: 'POST', body: 'not json' }),
error: 'Expected application/json',
},
{
name: 'malformed JSON',
request: new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{',
}),
error: 'Expected valid JSON',
},
{
name: 'invalid scene',
request: previewRequest({ ...previewPayload(), scene: { id: 'incomplete' } }),
error: 'Invalid scene',
},
{
name: 'mismatched stage',
request: previewRequest({
...previewPayload(),
stage: { id: 'other-stage', name: 'Other' },
}),
error: 'Stage context does not match scene.stageId',
},
{
name: 'oversized viewport',
request: previewRequest({
...previewPayload(),
viewport: { width: 4096, height: 4096, deviceScaleFactor: 2 },
}),
error: 'pixel limit',
},
])('maps $name to HTTP 400 before rendering', async ({ request, error }) => {
const render = vi.fn<PreviewRenderer['render']>();
const response = await appWith({ render }).fetch(request);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining(error) });
expect(render).not.toHaveBeenCalled();
});
it.each([
['null', null],
['undefined', undefined],
['missing-type', {}],
])('rejects a $name canvas element with HTTP 400, never 500', async (_name, element) => {
const payload = {
...previewPayload(),
scene: {
...previewPayload().scene,
content: {
...previewPayload().scene.content,
canvas: { ...previewPayload().scene.content.canvas, elements: [element] },
},
},
};
// JSON.stringify canonicalizes an undefined array slot to null. The unit
// test exercises the exact in-memory undefined value as well.
const request = previewRequest(payload);
const render = vi.fn<PreviewRenderer['render']>();
const response = await appWith({ render }).fetch(request);
expect(response.status).toBe(400);
expect(response.status).not.toBe(500);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('/content/canvas/elements/0'),
});
expect(render).not.toHaveBeenCalled();
});
it.each([
{
name: 'an empty slide canvas',
payload: {
...previewPayload(),
scene: {
...previewPayload().scene,
content: {
...previewPayload().scene.content,
canvas: { ...previewPayload().scene.content.canvas, elements: [] },
},
},
},
error: 'no renderable elements',
},
{
name: 'a slide with a non-data asset reference',
payload: {
...previewPayload(),
scene: {
...previewPayload().scene,
content: {
...previewPayload().scene.content,
canvas: {
...previewPayload().scene.content.canvas,
elements: [{ id: 'image-1', type: 'image', src: 'asset_opaque_1' }],
},
},
},
},
error: 'Scene is not self-contained: 1 slide media reference(s) must use data: URLs',
},
{
name: 'URL-only interactive content',
payload: {
...previewPayload(),
scene: {
...previewPayload().scene,
type: 'interactive',
content: { type: 'interactive', url: '/widget.html' },
},
},
error: 'non-empty embedded HTML',
},
{
name: 'interactive HTML with an external dependency',
payload: {
...previewPayload(),
scene: {
...previewPayload().scene,
type: 'interactive',
content: {
type: 'interactive',
html: '<!doctype html><script src="https://cdn.example.test/game.js"></script>',
},
},
},
error:
'Interactive HTML is not self-contained: 1 resource reference(s) must be inline or use data: URLs',
},
])('maps $name to HTTP 422 before rendering', async ({ payload, error }) => {
const render = vi.fn<PreviewRenderer['render']>();
const response = await appWith({ render }).fetch(previewRequest(payload));
expect(response.status).toBe(422);
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining(error) });
expect(render).not.toHaveBeenCalled();
});
it.each([
{
name: 'DOM depth',
limit: MAX_INTERACTIVE_HTML_DEPTH,
fixtureSize: MAX_INTERACTIVE_HTML_DEPTH + 1,
html: `${'<i>'.repeat(MAX_INTERACTIVE_HTML_DEPTH + 1)}content${'</i>'.repeat(MAX_INTERACTIVE_HTML_DEPTH + 1)}`,
},
{
name: 'element count',
limit: MAX_INTERACTIVE_HTML_ELEMENTS,
fixtureSize: MAX_INTERACTIVE_HTML_ELEMENTS + 1,
html:
'<!doctype html><body>' + '<i></i>'.repeat(MAX_INTERACTIVE_HTML_ELEMENTS + 1) + '</body>',
},
])('rejects interactive HTML beyond the $name ceiling with HTTP 422', async (testCase) => {
expect(testCase.fixtureSize).toBeGreaterThan(testCase.limit);
const payload = {
...previewPayload(),
scene: {
...previewPayload().scene,
type: 'interactive',
content: { type: 'interactive', html: testCase.html },
},
};
const render = vi.fn<PreviewRenderer['render']>();
const response = await appWith({ render }).fetch(previewRequest(payload));
expect(response.status).toBe(422);
expect(response.status).not.toBe(500);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining(`maximum ${testCase.name} of ${testCase.limit}`),
});
expect(render).not.toHaveBeenCalled();
});
it('rejects a declared oversized body with HTTP 413', async () => {
const render = vi.fn<PreviewRenderer['render']>();
const request = previewRequest();
request.headers.set('content-length', String(33 * 1024 * 1024));
const response = await appWith({ render }).fetch(request);
expect(response.status).toBe(413);
await expect(response.json()).resolves.toEqual({ error: 'Upload too large' });
expect(render).not.toHaveBeenCalled();
});
it('caps streamed preview JSON independently of the ZIP upload limit', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('x'.repeat(65)));
controller.close();
},
});
const request = new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
duplex: 'half',
} as RequestInit);
const render = vi.fn<PreviewRenderer['render']>();
const response = await appWith({ render }, new PreviewGate(8, 0), {
previewMaxJsonBytes: 64,
}).fetch(request);
expect(response.status).toBe(413);
await expect(response.json()).resolves.toEqual({ error: 'Upload too large' });
expect(render).not.toHaveBeenCalled();
});
it('rejects at admission before consuming the request body', async () => {
const gate = new PreviewGate(1, 0);
const release = gate.acquire('held');
let pulls = 0;
const body = new ReadableStream({
pull() {
pulls += 1;
},
});
const request = new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-openmaic-client': 'rejected' },
body,
duplex: 'half',
} as RequestInit);
await Promise.resolve();
const pullsBeforeFetch = pulls;
const response = await appWith({ render: async () => new Uint8Array([1]) }, gate).fetch(
request,
);
expect(response.status).toBe(429);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining('preview queue is full'),
reason: 'preview_queue_full',
});
expect(pulls).toBe(pullsBeforeFetch);
release();
});
it('enforces the per-identity cap while allowing another identity', async () => {
let finish!: () => void;
const parked = new Promise<void>((resolve) => {
finish = resolve;
});
const app = appWith(
{ render: async () => (await parked, new Uint8Array([1])) },
new PreviewGate(8, 1),
);
const first = app.fetch(previewRequest(previewPayload(), 'alice'));
await new Promise((resolve) => setTimeout(resolve, 10));
const rejected = await app.fetch(previewRequest(previewPayload(), 'alice'));
const other = app.fetch(previewRequest(previewPayload(), 'bob'));
expect(rejected.status).toBe(429);
await expect(rejected.json()).resolves.toMatchObject({
reason: 'preview_per_user_limit',
});
const otherResponse = await other;
expect(otherResponse.status).toBe(429);
await expect(otherResponse.json()).resolves.toMatchObject({ reason: 'capacity_busy' });
finish();
expect((await first).status).toBe(200);
});
it('maps renderer deadlines to 504 and other failures to 500', async () => {
const timedOut = await appWith({
render: async () => {
throw new PreviewTimeoutError('Preview exceeded the deadline');
},
}).fetch(previewRequest());
expect(timedOut.status).toBe(504);
const failed = await appWith({
render: async () => {
throw new Error('Chromium launch failed');
},
}).fetch(previewRequest());
expect(failed.status).toBe(500);
await expect(failed.json()).resolves.toEqual({ error: 'Chromium launch failed' });
});
it('applies the deadline while waiting for an extraction permit', async () => {
const extractionGate = new Semaphore(1);
let releasePermit!: () => void;
const held = extractionGate.run(
() =>
new Promise<void>((resolve) => {
releasePermit = resolve;
}),
);
await Promise.resolve();
const render = vi.fn<PreviewRenderer['render']>(async () => new Uint8Array([1]));
const app = appWith({ render }, new PreviewGate(8, 2), {
extractionGate,
previewDeadlineMs: 20,
});
const started = Date.now();
const response = await app.fetch(previewRequest());
expect(response.status).toBe(504);
expect(Date.now() - started).toBeLessThan(500);
expect(render).not.toHaveBeenCalled();
releasePermit();
await held;
const next = await app.fetch(previewRequest());
expect(next.status).toBe(200);
});
it('cancels a stalled body read when the preview deadline expires', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"version":'));
},
pull() {
return new Promise<void>(() => {});
},
cancel() {
cancelled = true;
},
});
const request = new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
duplex: 'half',
} as RequestInit);
const render = vi.fn<PreviewRenderer['render']>(async () => new Uint8Array([1]));
const app = appWith({ render }, new PreviewGate(8, 2), { previewDeadlineMs: 20 });
const started = Date.now();
const response = await app.fetch(request);
expect(response.status).toBe(504);
expect(Date.now() - started).toBeLessThan(500);
expect(cancelled).toBe(true);
expect(render).not.toHaveBeenCalled();
const next = await app.fetch(previewRequest());
expect(next.status).toBe(200);
});
it('releases the extraction permit after parsing and never queues for execution', async () => {
const finishFirst = deferred();
let renderCalls = 0;
const app = appWith({
render: async () => {
renderCalls += 1;
if (renderCalls === 1) await finishFirst.promise;
return new Uint8Array([1]);
},
});
const first = app.fetch(previewRequest(previewPayload(), 'first'));
await waitFor(() => renderCalls === 1);
const bytes = new TextEncoder().encode(JSON.stringify(previewPayload()));
let offset = 0;
let pulls = 0;
const secondBody = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
if (offset >= bytes.byteLength) {
controller.close();
return;
}
const next = Math.min(offset + 32, bytes.byteLength);
controller.enqueue(bytes.slice(offset, next));
offset = next;
},
});
const secondRequest = new Request('http://test/preview', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-openmaic-client': 'second' },
body: secondBody,
duplex: 'half',
} as RequestInit);
await Promise.resolve();
const pullsBeforeFetch = pulls;
const second = app.fetch(secondRequest);
const secondResponse = await second;
expect(renderCalls).toBe(1);
expect(pulls).toBeGreaterThan(pullsBeforeFetch);
expect(secondResponse.status).toBe(429);
await expect(secondResponse.json()).resolves.toMatchObject({ reason: 'capacity_busy' });
finishFirst.resolve();
expect((await first).status).toBe(200);
expect(renderCalls).toBe(1);
});
it('fast-rejects when a video render holds the shared Chromium execution limit', async () => {
const videoStarted = deferred();
const finishVideo = deferred();
const executor: RenderExecutor = {
async execute() {
videoStarted.resolve();
await finishVideo.promise;
return { status: 'succeeded' };
},
};
const jobs = createMemoryJobStore();
const artifacts = createMemoryArtifactStore().store;
const coordinator = new RenderCoordinator(executor, jobs, artifacts, { maxConcurrency: 1 });
const videoId = await coordinator.submit(
coordinator.reserve('video-user'),
'/tmp/openmaic-preview-route-video-test',
{ fps: 30, quality: 'standard', format: 'mp4' },
);
await videoStarted.promise;
const render = vi.fn<PreviewRenderer['render']>(async () => new Uint8Array([1]));
const app = createApp({
jobs,
artifacts,
coordinator,
extractionGate: new Semaphore(1),
previewRenderer: { render },
previewDeadlineMs: 1_000,
});
const started = Date.now();
const preview = await app.fetch(previewRequest());
expect(Date.now() - started).toBeLessThan(500);
expect(render).not.toHaveBeenCalled();
expect(preview.status).toBe(429);
await expect(preview.json()).resolves.toMatchObject({ reason: 'capacity_busy' });
finishVideo.resolve();
for (let attempt = 0; attempt < 100; attempt += 1) {
if ((await jobs.get(videoId))?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect((await jobs.get(videoId))?.status).toBe('succeeded');
expect((await app.fetch(previewRequest())).status).toBe(200);
expect(render).toHaveBeenCalledOnce();
});
it('aborts rendering and releases admission when the client disconnects', async () => {
const rendering = deferred();
let calls = 0;
const render = vi.fn<PreviewRenderer['render']>(async ({ signal }) => {
calls += 1;
if (calls > 1) return new Uint8Array([1]);
rendering.resolve();
return new Promise<Uint8Array>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const app = appWith({ render }, new PreviewGate(1, 0));
const disconnected = new AbortController();
const request = previewRequest();
const abortableRequest = new Request(request, { signal: disconnected.signal });
const responsePromise = app.fetch(abortableRequest);
await rendering.promise;
disconnected.abort(new Error('client disconnected'));
const response = await responsePromise;
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: 'client disconnected' });
expect((await app.fetch(previewRequest())).status).toBe(200);
});
});
@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import type { PreviewScene } from '../src/preview-renderer.js';
import {
MAX_INTERACTIVE_HTML_DEPTH,
MAX_INTERACTIVE_HTML_ELEMENTS,
countNonSelfContainedSlideMediaReferences,
findNonSelfContainedInteractiveReferences,
previewabilityError,
} from '../src/preview-validation.js';
function slideScene(canvas: Record<string, unknown>): Extract<PreviewScene, { type: 'slide' }> {
return {
id: 'scene-1',
stageId: 'stage-1',
order: 1,
title: 'Preview',
type: 'slide',
content: { type: 'slide', canvas },
actions: [],
} as unknown as Extract<PreviewScene, { type: 'slide' }>;
}
function interactiveScene(html?: string): Extract<PreviewScene, { type: 'interactive' }> {
return {
id: 'interactive-1',
stageId: 'stage-1',
order: 1,
title: 'Widget',
type: 'interactive',
content: { type: 'interactive', ...(html === undefined ? { url: '/widget' } : { html }) },
actions: [],
};
}
describe('preview payload semantic validation', () => {
it('accepts a background-only slide canvas', () => {
expect(
previewabilityError(
slideScene({
background: { type: 'solid', color: '#ffffff' },
elements: [],
}),
),
).toBeUndefined();
});
it('rejects a slide canvas with neither elements nor a background', () => {
expect(previewabilityError(slideScene({ elements: [] }))).toBe(
'Slide canvas has no renderable elements',
);
});
it.each([
['null', null],
['undefined', undefined],
['an object without type', {}],
])('rejects %s slide canvas elements without throwing', (_name, element) => {
const scene = slideScene({ elements: [element] });
expect(() => previewabilityError(scene)).not.toThrow();
expect(previewabilityError(scene)).toContain('/content/canvas/elements/0');
expect(() => countNonSelfContainedSlideMediaReferences(scene)).not.toThrow();
});
it('rejects every non-data slide media class across the exact DSL slots', () => {
const scene = slideScene({
background: { type: 'image', image: { src: 'https://example.test/background.png' } },
elements: [
{ id: 'image', type: 'image', src: 'blob:image' },
{ id: 'audio', type: 'audio', src: './audio.mp3' },
{ id: 'video', type: 'video', mediaRef: 'asset_video', poster: '/poster.png' },
],
});
expect(countNonSelfContainedSlideMediaReferences(scene)).toBe(5);
expect(previewabilityError(scene)).toBe(
'Scene is not self-contained: 5 slide media reference(s) must use data: URLs',
);
});
it('accepts data URLs everywhere and ignores a stale video mediaRef shadowed by data src', () => {
const scene = slideScene({
background: { type: 'image', image: { src: 'data:image/png;base64,AA==' } },
elements: [
{ id: 'image', type: 'image', src: 'data:image/png;base64,AA==' },
{ id: 'audio', type: 'audio', src: 'data:audio/mpeg;base64,AA==' },
{
id: 'video',
type: 'video',
src: 'data:video/mp4;base64,AA==',
mediaRef: 'asset_stale_video',
poster: 'data:image/png;base64,AA==',
},
],
});
expect(countNonSelfContainedSlideMediaReferences(scene)).toBe(0);
expect(previewabilityError(scene)).toBeUndefined();
});
it.each([
['HTTP script src', '<script src="https://cdn.example.test/app.js"></script>'],
['blob image src', '<img src="blob:image">'],
['relative audio src', '<audio src="./sound.mp3"></audio>'],
['opaque asset ref', '<video src="asset_video"></video>'],
['external stylesheet', '<link rel="stylesheet" href="https://cdn.example.test/app.css">'],
['non-data srcset candidate', '<img srcset="data:image/png;base64,AA== 1x, /image@2x.png 2x">'],
['CSS url in a style attribute', '<div style="background:url(/background.png)"></div>'],
[
'HTTPS CSS url in a style attribute',
'<div style="background:url(https://cdn.example.test/background.png)"></div>',
],
[
'CSS url in a style element',
'<style>@font-face { src: url(https://cdn.example.test/font.woff2) }</style>',
],
['iframe src', '<iframe src="/embedded.html"></iframe>'],
])('rejects %s', (_name, resource) => {
const html = `<!doctype html><html><head></head><body>${resource}</body></html>`;
expect(findNonSelfContainedInteractiveReferences(html)).toHaveLength(1);
expect(previewabilityError(interactiveScene(html))).toBe(
'Interactive HTML is not self-contained: 1 resource reference(s) must be inline or use data: URLs',
);
});
it('accepts data resources, an external canonical link, and an iframe without src', () => {
const html = `<!doctype html><html><head>
<link rel="canonical" href="https://example.test/canonical">
<link rel="stylesheet" href="data:text/css,body%7Bcolor%3Ablack%7D">
<link rel="apple-touch-icon" href="data:image/png;base64,AA==">
<style>@font-face { src: url(data:font/woff2;base64,AA==) }</style>
<script src="data:text/javascript,window.ready=true"></script>
<script>window.inline = true</script>
</head><body style="background:url('data:image/png;base64,AA==')">
<img src="data:image/png;base64,AA==" srcset="data:image/png;base64,AA== 1x, data:image/png;base64,AA== 2x">
<video src="data:video/mp4;base64,AA==" poster="data:image/png;base64,AA==">
<source src="data:video/mp4;base64,AA==">
</video>
<audio src="data:audio/mpeg;base64,AA=="></audio>
<iframe></iframe><iframe src="data:text/html,ready"></iframe>
<embed src="data:text/html,ready"><object href="data:text/plain,ready"></object>
</body></html>`;
expect(findNonSelfContainedInteractiveReferences(html)).toEqual([]);
expect(previewabilityError(interactiveScene(html))).toBeUndefined();
});
it('accepts fragment-only CSS URLs that reference inline SVG definitions', () => {
const html = `<!doctype html><html><head>
<style>.clipped { clip-path: url('#clip') }</style>
</head><body>
<svg aria-hidden="true">
<defs>
<filter id="shadow"><feDropShadow dx="1" dy="1" stdDeviation="1"></feDropShadow></filter>
<clipPath id="clip"><circle cx="50" cy="50" r="40"></circle></clipPath>
</defs>
</svg>
<div class="clipped" style="filter: url(#shadow)"></div>
</body></html>`;
expect(findNonSelfContainedInteractiveReferences(html)).toEqual([]);
expect(previewabilityError(interactiveScene(html))).toBeUndefined();
});
it('accepts an inline-only interactive page and rejects missing or blank HTML', () => {
expect(
previewabilityError(
interactiveScene(
'<!doctype html><style>body { color: green }</style><p>Ready</p><script>document.body.dataset.ready = "true"</script>',
),
),
).toBeUndefined();
expect(previewabilityError(interactiveScene())).toContain('non-empty embedded HTML');
expect(previewabilityError(interactiveScene(' '))).toContain('non-empty embedded HTML');
});
it('rejects interactive HTML beyond the DOM depth ceiling', () => {
const nestingDepth = MAX_INTERACTIVE_HTML_DEPTH + 1;
expect(nestingDepth).toBeGreaterThan(MAX_INTERACTIVE_HTML_DEPTH);
const html = `${'<i>'.repeat(nestingDepth)}content${'</i>'.repeat(nestingDepth)}`;
expect(() => findNonSelfContainedInteractiveReferences(html)).toThrow(
`maximum DOM depth of ${MAX_INTERACTIVE_HTML_DEPTH}`,
);
expect(previewabilityError(interactiveScene(html))).toContain(
`maximum DOM depth of ${MAX_INTERACTIVE_HTML_DEPTH}`,
);
});
it('rejects interactive HTML beyond the element-count ceiling', () => {
const elementCount = MAX_INTERACTIVE_HTML_ELEMENTS + 1;
expect(elementCount).toBeGreaterThan(MAX_INTERACTIVE_HTML_ELEMENTS);
const html = '<!doctype html><body>' + '<i></i>'.repeat(elementCount) + '</body>';
expect(() => findNonSelfContainedInteractiveReferences(html)).toThrow(
`maximum element count of ${MAX_INTERACTIVE_HTML_ELEMENTS}`,
);
expect(previewabilityError(interactiveScene(html))).toContain(
`maximum element count of ${MAX_INTERACTIVE_HTML_ELEMENTS}`,
);
});
});
@@ -69,6 +69,41 @@ async function waitForCleanup(path: string): Promise<void> {
const renderOptions = { fps: 30, quality: 'standard', format: 'mp4' } as const;
describe('RenderCoordinator through the RenderExecutor seam', () => {
it('keeps a dispatched video queued until the shared execution slot is acquired', async () => {
const jobs = createMemoryJobStore();
const artifacts = createMemoryArtifactStore();
let releasePreview!: () => void;
const previewParked = new Promise<void>((resolve) => {
releasePreview = resolve;
});
let finishVideo!: () => void;
const videoParked = new Promise<void>((resolve) => {
finishVideo = resolve;
});
const executor = new FakeExecutor(async () => {
await videoParked;
return { status: 'succeeded' };
});
const coordinator = new RenderCoordinator(executor, jobs, artifacts.store, {
maxConcurrency: 1,
});
const preview = coordinator.tryRunWithExecutionSlot(() => previewParked);
expect(preview).toBeDefined();
const dir = await projectDir();
const id = await coordinator.submit(coordinator.reserve('video-user'), dir, renderOptions);
await Promise.resolve();
expect(await jobs.get(id)).toMatchObject({ status: 'queued', currentStage: 'queued' });
expect(executor.requests).toHaveLength(0);
releasePreview();
await preview;
await waitForJob(jobs, id, () => executor.requests.length === 1);
expect(await jobs.get(id)).toMatchObject({ status: 'running', currentStage: 'preparing' });
finishVideo();
await waitForJob(jobs, id, (job) => job.status === 'succeeded');
});
it('persists normalized progress, performance, and the artifact on success', async () => {
const jobs = createMemoryJobStore();
const artifacts = createMemoryArtifactStore();
@@ -16,6 +16,8 @@ describe('resource profiles', () => {
producerWorkers: 1,
maxConcurrency: 1,
maxConcurrentExtractions: 1,
maxPreviewPixels: 3840 * 2160,
maxPreviewDeviceScaleFactor: 2,
minimumMemoryBytes: 8 * GIB,
});
expect(env).toMatchObject({
@@ -39,6 +41,8 @@ describe('resource profiles', () => {
requestedCaptureMode: 'screenshot',
requireBeginFrame: false,
producerWorkers: 1,
maxPreviewPixels: 1920 * 1080,
maxPreviewDeviceScaleFactor: 1,
minimumMemoryBytes: 4 * GIB,
});
expect(env).toMatchObject({
+31
View File
@@ -61,4 +61,35 @@ describe('Semaphore', () => {
]);
expect(results).toEqual([1, 2, 3]);
});
it('removes an aborted waiter without consuming the next permit', async () => {
const sem = new Semaphore(1);
const held = deferred();
const first = sem.run(() => held.promise);
await Promise.resolve();
const abort = new AbortController();
const stale = sem.run(async () => 'stale', abort.signal);
const next = sem.run(async () => 'next');
abort.abort(new Error('deadline'));
await expect(stale).rejects.toThrow('deadline');
held.resolve();
await first;
await expect(next).resolves.toBe('next');
});
it('tryAcquire never queues and returns an idempotent release', async () => {
const sem = new Semaphore(1);
const release = sem.tryAcquire();
expect(release).toBeTypeOf('function');
expect(sem.tryAcquire()).toBeUndefined();
release?.();
release?.();
const next = sem.tryAcquire();
expect(next).toBeTypeOf('function');
expect(sem.tryAcquire()).toBeUndefined();
next?.();
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",