mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+870
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
Reference in New Issue
Block a user