Files
hyperframes/packages/producer/src/server.ts
T
Vance IngallsandClaude Opus 5 349c066a83 fix(producer): classify JSON error bodies as non-media sources too
A source that answers with a JSON error body still reached ffprobe and
produced `moov atom not found`. Replicate returns
`{"detail": "requested file not found"}` for a dead asset, and a gateway
in front of it can relay that body with a success status.

The sniff now treats `<`, `{`, or `[` as the opening byte of a text
document. No supported container starts with any of them, so this is the
same trade as before: three bytes instead of an allowlist that grows one
entry per payload shape observed in production.

Renamed accordingly, since the class now covers JSON as well as markup:
MARKUP_NOT_MEDIA -> NOT_MEDIA_PAYLOAD, MarkupNotMediaError ->
NotMediaPayloadError, markupPayload.ts -> notMediaPayload.ts. Registry
entries in the Lambda name map, the CDK and SAM plan lists, the Cloud Run
set, and SAFE_RENDER_ERROR_CODES move with it.

Also documents the reachability boundary on the error class: only a 2xx
response gets here. `downloadToTemp` rejects 404/410 as `http_not_found`
before writing a byte, and every ffprobe input is local because
videoFrameExtractor downloads http srcs first. So the shapes this
classifies are soft-404 and interstitial HTML, S3/CloudFront error
documents, and JSON API error bodies -- each served with a success
status. A genuine 404 surfaces as a download failure, not as this error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 03:16:11 -07:00

1026 lines
36 KiB
JavaScript

#!/usr/bin/env node
/**
* @hyperframes/producer — Public Server
*
* Clean HTTP API for rendering HTML compositions to video.
*
* Routes:
* POST /render — blocking render, returns JSON
* POST /render/stream — SSE streaming render with progress
* GET /render/queue — current render queue status
* POST /lint — blocking Hyperframe lint
* GET /health — health check
* GET /outputs/:token — download rendered MP4
*/
import {
existsSync,
mkdirSync,
statSync,
mkdtempSync,
writeFileSync,
rmSync,
createReadStream,
} from "node:fs";
import { resolve, dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseArgs } from "node:util";
import crypto from "node:crypto";
import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { serve } from "@hono/node-server";
import {
RenderCancelledError,
createRenderJob,
executeRenderJob,
type ProgressCallback,
type RenderConfig,
type RenderJob,
} from "./services/renderOrchestrator.js";
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
import { startHealthWorker, type HealthWorkerHandle } from "./services/healthWorker.js";
import { isVideoFrameFormat } from "@hyperframes/engine";
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
import {
parseFps,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
type CanvasResolution,
} from "@hyperframes/core";
import { createRenderRequest, renderConfigFromRequest } from "./renderRequest.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HandlerOptions {
/** Custom logger. Defaults to console-based defaultLogger. */
logger?: ProducerLogger;
/** Extract or generate a request ID. Defaults to x-request-id header or random UUID. */
getRequestId?: (c: Context) => string;
/** Directory for rendered output files. Defaults to PRODUCER_RENDERS_DIR or /tmp. */
rendersDir?: string;
/** Prefix for output URLs in responses. Default: "/outputs". */
outputUrlPrefix?: string;
/** TTL for output artifact download tokens (ms). Default: 15 minutes. */
artifactTtlMs?: number;
/** Max renders that execute simultaneously. Queued requests wait FIFO. Default: 2. */
maxConcurrentRenders?: number;
}
export interface ServerOptions extends HandlerOptions {
/** Port to listen on. Default: 9847. */
port?: number;
}
// ---------------------------------------------------------------------------
// Shared validation helpers
// ---------------------------------------------------------------------------
interface RenderInput {
projectDir: string;
outputPath?: string | null;
fps: import("@hyperframes/core").Fps;
quality: "draft" | "standard" | "high";
format?: "mp4" | "webm" | "mov";
videoFrameFormat?: RenderConfig["videoFrameFormat"];
outputDynamicRange?: "auto" | "hdr" | "sdr";
workers?: number;
useGpu: boolean;
debug: boolean;
strictness: RenderConfig["strictness"];
entryFile?: string;
/**
* data-composition-variables overrides forwarded into the render config.
* Without this the HTTP/server render path silently rendered the
* composition's declared defaults, ignoring per-request overrides.
*/
variables?: Record<string, unknown>;
/**
* Output resolution preset (e.g. `landscape-4k`). Drives the same
* `resolveDeviceScaleFactor` supersampling path the local CLI uses — Chrome
* renders at a higher devicePixelRatio so the captured screenshot lands at
* the requested dimensions. Aspect ratio must match the composition unless
* `outputResolutionAspectAgnostic` is set (see below).
*/
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`). The compile stage will adapt the preset to
* the composition's orientation instead of rejecting portrait/square
* compositions as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
}
interface PreparedRenderInput {
input: RenderInput;
cleanupProjectDir?: string;
}
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"ASSET_MEDIA_TYPE_MISMATCH",
"NOT_MEDIA_PAYLOAD",
"INVALID_VIDEO_METADATA",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
]);
/**
* Preserve only bounded producer error codes across JSON/SSE. Never derive a
* code from the message: it may contain local paths or signed source URLs.
*/
export function extractSafeRenderErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
const code = error.code;
return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : undefined;
}
export interface SafeRenderErrorMetadata {
errorCode: string;
errorOwner?: "system" | "user";
retryable?: boolean;
}
/** Additive bounded metadata for typed producer failures. */
export function extractSafeRenderErrorMetadata(
error: unknown,
): SafeRenderErrorMetadata | undefined {
const errorCode = extractSafeRenderErrorCode(error);
if (!errorCode || typeof error !== "object" || error === null) return undefined;
const owner = "owner" in error ? error.owner : undefined;
const retryable = "retryable" in error ? error.retryable : undefined;
return {
errorCode,
errorOwner: owner === "user" || owner === "system" ? owner : undefined,
retryable: typeof retryable === "boolean" ? retryable : undefined,
};
}
function parseServerFps(value: unknown): RenderInput["fps"] {
if (typeof value !== "number" && typeof value !== "string") return DEFAULT_SERVER_FPS;
const parsed = parseFps(value);
return parsed.ok ? parsed.value : DEFAULT_SERVER_FPS;
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseOutputCandidate(body: Record<string, unknown>): string | null {
return nonEmptyString(body.outputPath) ?? nonEmptyString(body.output) ?? null;
}
function parseServerQuality(value: unknown): RenderInput["quality"] {
return value === "draft" || value === "standard" || value === "high" ? value : "high";
}
function parseServerFormat(value: unknown): RenderInput["format"] {
return value === "mp4" || value === "webm" || value === "mov" ? value : undefined;
}
function parseServerOutputDynamicRange(value: unknown): RenderInput["outputDynamicRange"] {
return value === "auto" || value === "hdr" || value === "sdr" ? value : undefined;
}
function parseLegacyServerHdrMode(value: unknown): RenderConfig["hdrMode"] {
return value === "auto" || value === "force-hdr" || value === "force-sdr" ? value : undefined;
}
function fromRenderHdrMode(hdrMode: RenderConfig["hdrMode"]): RenderInput["outputDynamicRange"] {
if (hdrMode === "force-hdr") return "hdr";
if (hdrMode === "force-sdr") return "sdr";
return hdrMode;
}
function toRenderHdrMode(
outputDynamicRange: RenderInput["outputDynamicRange"],
): RenderConfig["hdrMode"] {
if (outputDynamicRange === "hdr") return "force-hdr";
if (outputDynamicRange === "sdr") return "force-sdr";
return outputDynamicRange;
}
export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
// forgiving behaviour the original whitelist had — the producer surfaces a
// clearer downstream error if the value is genuinely unusable.
const fps = parseServerFps(body.fps);
const quality = parseServerQuality(body.quality);
const workers = typeof body.workers === "number" ? body.workers : undefined;
const useGpu = body.gpu === true;
const debug = body.debug === true;
// Preserve the pre-structured-warning HTTP contract for callers that do
// not yet send this field. Strict readiness is an explicit opt-in via
// `bestEffort: false`; omission must keep producing degraded output with
// structured warnings while downstream callers migrate.
const strictness = body.bestEffort === false ? "strict" : "best-effort";
const outputPath = parseOutputCandidate(body);
const entryFile = nonEmptyString(body.entryFile);
const format = parseServerFormat(body.format);
const outputDynamicRange =
parseServerOutputDynamicRange(body.outputDynamicRange) ??
fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
? body.videoFrameFormat
: undefined;
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
return {
outputPath,
fps,
quality,
workers,
useGpu,
debug,
strictness,
entryFile,
format,
outputDynamicRange,
variables,
outputResolution,
outputResolutionAspectAgnostic,
videoFrameFormat,
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Parse the lenient form of the variable + resolution overrides used by
* `parseRenderOptions`. Invalid shapes coerce to `undefined` here;
* `validateRenderOverrides` separately rejects explicitly-supplied bad values
* with a 400 so they aren't silently ignored.
*/
function parseRenderOverrides(body: Record<string, unknown>): {
variables?: Record<string, unknown>;
outputResolution?: CanvasResolution;
outputResolutionAspectAgnostic?: boolean;
} {
// Only forward a plain JSON object. Arrays / primitives / null → undefined.
const variables = isPlainObject(body.variables) ? body.variables : undefined;
// Accept canonical presets and aliases ("4k", "landscape-4k", …).
const rawOutputResolution =
typeof body.outputResolution === "string" ? body.outputResolution : undefined;
const outputResolution = rawOutputResolution
? normalizeResolutionFlag(rawOutputResolution)
: undefined;
// Preserve the "raw shape was tier-only" signal so the compile stage can
// adapt the preset to the composition's orientation. Set only when
// normalization succeeded — a bad string doesn't need the flag.
const outputResolutionAspectAgnostic = outputResolution
? isAspectAgnosticResolutionAlias(rawOutputResolution)
: undefined;
return { variables, outputResolution, outputResolutionAspectAgnostic };
}
/**
* Build the `createRenderJob` config from a prepared render input. Shared by
* the sync (`render`) and streaming (`render-stream`) handlers so the field
* set — including `variables` and `outputResolution` — stays in one place.
*/
function buildRenderJobConfig(input: RenderInput, outputPath: string, log: ProducerLogger) {
const request = createRenderRequest({
projectDir: input.projectDir,
outputPath,
options: {
fps: input.fps,
quality: input.quality,
format: input.format ?? "mp4",
workers: input.workers,
useGpu: input.useGpu,
debug: input.debug,
strictness: input.strictness,
entryFile: input.entryFile,
variables: input.variables,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
videoFrameFormat: input.videoFrameFormat,
hdrMode: toRenderHdrMode(input.outputDynamicRange),
},
});
return renderConfigFromRequest(request, { logger: log });
}
/**
* Resolve the destination path for a prepared render and ensure its parent
* directory exists. Shared by the sync + streaming handlers (their only
* difference is how a `prepareRenderBody` error is surfaced — JSON vs SSE —
* which stays in each handler).
*/
function resolvePreparedRenderOutput(
prepared: PreparedRenderInput,
rendersDir: string,
log: ProducerLogger,
): { input: RenderInput; cleanupProjectDir?: string; absoluteOutputPath: string } {
const { input, cleanupProjectDir } = prepared;
const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log);
const outputDir = dirname(absoluteOutputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
return { input, cleanupProjectDir, absoluteOutputPath };
}
/**
* Validate explicitly-supplied render overrides that can't be sanely coerced.
* Returns an error string for a clean 400, or `undefined` when the body is
* acceptable (including when the fields are simply absent).
*/
function validateRenderOverrides(body: Record<string, unknown>): string | undefined {
if (body.variables !== undefined && !isPlainObject(body.variables)) {
return 'variables must be a JSON object keyed by variable id (e.g. {"title":"Hello"})';
}
if (
body.outputDynamicRange !== undefined &&
parseServerOutputDynamicRange(body.outputDynamicRange) === undefined
) {
return 'outputDynamicRange must be one of: "auto", "hdr", "sdr"';
}
const legacyHdrMode = parseLegacyServerHdrMode(body.hdrMode);
if (body.hdrMode !== undefined && legacyHdrMode === undefined) {
return 'legacy hdrMode must be one of: "auto", "force-hdr", "force-sdr"';
}
const outputDynamicRange = parseServerOutputDynamicRange(body.outputDynamicRange);
if (
outputDynamicRange !== undefined &&
legacyHdrMode !== undefined &&
outputDynamicRange !== fromRenderHdrMode(legacyHdrMode)
) {
return "outputDynamicRange and legacy hdrMode must describe the same output policy";
}
return validateOutputResolutionOverride(body);
}
/**
* Validate an explicitly-supplied `outputResolution`. Rejects (a) non-string
* values, which parseRenderOverrides would otherwise silently coerce to
* `undefined`; (b) unknown presets; and (c) the alpha-format combination —
* outputResolution drives deviceScaleFactor supersampling, which the webm/mov
* capture path can't apply (resolveDeviceScaleFactor throws mid-render), so we
* reject it here for a clean 400 regardless of which caller sent it.
*/
function validateOutputResolutionOverride(body: Record<string, unknown>): string | undefined {
if (body.outputResolution === undefined) return undefined;
if (typeof body.outputResolution !== "string") {
return 'outputResolution must be a string preset (e.g. "4k", "landscape-4k")';
}
const normalized = normalizeResolutionFlag(body.outputResolution);
if (body.outputResolution.trim().length > 0 && normalized === undefined) {
return `Invalid outputResolution "${body.outputResolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: 1080p, 4k, …).`;
}
if (normalized !== undefined && (body.format === "webm" || body.format === "mov")) {
return `outputResolution is not supported with format "${body.format}" — the alpha (webm/mov) capture path can't supersample. Use format "mp4", or omit outputResolution to render at the composition's native dimensions.`;
}
return undefined;
}
type PrepareRenderResult = { prepared: PreparedRenderInput } | { error: string };
function prepareProjectDirectory(
projectDir: unknown,
options: Omit<RenderInput, "projectDir">,
): PrepareRenderResult | null {
const candidate = nonEmptyString(projectDir);
if (!candidate) return null;
const absProjectDir = resolve(candidate);
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
return { error: `Project directory not found: ${absProjectDir}` };
}
const entry = options.entryFile || "index.html";
if (!existsSync(resolve(absProjectDir, entry))) {
return { error: `Entry file "${entry}" not found in project directory: ${absProjectDir}` };
}
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
}
async function resolveInlineRenderHtml(body: Record<string, unknown>): Promise<
| { html: string }
| {
error: string;
}
> {
const inlineHtml = typeof body.html === "string" ? body.html : "";
if (inlineHtml) return { html: inlineHtml };
const previewUrl = nonEmptyString(body.previewUrl);
if (!previewUrl)
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
try {
const response = await fetch(previewUrl, { method: "GET" });
if (!response.ok) {
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
}
return { html: await response.text() };
} catch (error) {
return {
error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function materializeInlineProject(
html: string,
options: Omit<RenderInput, "projectDir">,
): PrepareRenderResult {
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
writeFileSync(join(tempProjectDir, "index.html"), html, "utf-8");
return {
prepared: {
input: { projectDir: tempProjectDir, ...options },
cleanupProjectDir: tempProjectDir,
},
};
}
export async function prepareRenderBody(
body: Record<string, unknown>,
): Promise<PrepareRenderResult> {
// Reject explicitly-supplied-but-malformed overrides up front so the caller
// gets a clear 400 instead of a silently-ignored value.
const overrideError = validateRenderOverrides(body);
if (overrideError) return { error: overrideError };
const options = parseRenderOptions(body);
const project = prepareProjectDirectory(body.projectDir, options);
if (project) return project;
const source = await resolveInlineRenderHtml(body);
return "error" in source ? source : materializeInlineProject(source.html, options);
}
function resolveOutputPath(
projectDir: string,
outputCandidate: string | null | undefined,
rendersDir: string,
log: ProducerLogger,
): string {
try {
return resolveRenderPaths(projectDir, outputCandidate, rendersDir).absoluteOutputPath;
} catch (error) {
const fallbackPath = resolve(rendersDir, `producer-fallback-${Date.now()}.mp4`);
log.warn("Failed to resolve output path, using fallback", {
fallback: fallbackPath,
error: error instanceof Error ? error.message : String(error),
});
return fallbackPath;
}
}
// ---------------------------------------------------------------------------
// Output artifact management
// ---------------------------------------------------------------------------
interface OutputArtifact {
path: string;
expiresAtMs: number;
}
function createArtifactStore(ttlMs: number) {
const artifacts = new Map<string, OutputArtifact>();
const cleanup = setInterval(() => {
const now = Date.now();
for (const [token, artifact] of artifacts.entries()) {
if (artifact.expiresAtMs <= now) {
artifacts.delete(token);
}
}
}, 60_000);
cleanup.unref();
return {
register(path: string): string {
const token = crypto.randomUUID();
artifacts.set(token, { path, expiresAtMs: Date.now() + ttlMs });
return token;
},
get(token: string): OutputArtifact | undefined {
return artifacts.get(token);
},
delete(token: string) {
artifacts.delete(token);
},
};
}
function cleanupTempDir(dir: string | undefined, log: ProducerLogger): void {
if (!dir) return;
try {
rmSync(dir, { recursive: true, force: true });
} catch (error) {
log.warn("Failed to cleanup temp project dir", {
cleanupProjectDir: dir,
error: error instanceof Error ? error.message : String(error),
});
}
}
function outputFileSize(path: string): number {
return existsSync(path) ? statSync(path).size : 0;
}
function createBlockingProgressReporter(log: ProducerLogger, requestId: string): ProgressCallback {
let lastLoggedPct = -10;
return (job, message) => {
const pct = job.progress;
if (pct < lastLoggedPct + 10) return;
lastLoggedPct = pct;
log.info(`render progress ${pct}%`, { requestId, stage: job.currentStage, message });
};
}
interface SseWriter {
writeSSE(event: { data: string }): Promise<void>;
}
async function prepareSseRenderRequest(
c: Context,
stream: SseWriter,
requestId: string,
): Promise<PreparedRenderInput | null> {
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: "Invalid JSON body",
stage: "validation",
}),
});
return null;
}
const prepared = await prepareRenderBody(body);
if (!("error" in prepared)) return prepared.prepared;
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: prepared.error,
stage: "validation",
}),
});
return null;
}
function createSseProgressReporter(stream: SseWriter, requestId: string): ProgressCallback {
return async (job, message) => {
await stream.writeSSE({
data: JSON.stringify({
type: "progress",
requestId,
stage: job.currentStage,
progress: job.progress,
framesRendered: job.framesRendered ?? 0,
totalFrames: job.totalFrames ?? 0,
message,
}),
});
};
}
async function writeRenderStreamFailure(input: {
error: unknown;
job: RenderJob;
stream: SseWriter;
requestId: string;
startedAtMs: number;
log: ProducerLogger;
}): Promise<void> {
const { error, job, stream, requestId, startedAtMs, log } = input;
if (error instanceof RenderCancelledError) {
await stream.writeSSE({
data: JSON.stringify({
type: "cancelled",
requestId,
stage: job.currentStage,
outcome: job.outcome ?? "cancelled",
message: error.message,
}),
});
return;
}
const errorMsg = error instanceof Error ? error.message : String(error);
const safeError = extractSafeRenderErrorMetadata(error);
const elapsedMs = Date.now() - startedAtMs;
log.error("render-stream failed", {
requestId,
elapsedMs,
error: errorMsg,
stage: job.currentStage,
});
await stream.writeSSE({
data: JSON.stringify({
type: "error",
requestId,
error: errorMsg,
errorCode: safeError?.errorCode,
errorOwner: safeError?.errorOwner,
retryable: safeError?.retryable,
stage: job.currentStage,
elapsedMs,
errorDetails: job.errorDetails ?? null,
outcome: job.outcome ?? "failed",
warnings: job.warnings,
}),
});
}
// ---------------------------------------------------------------------------
// Handler factory
// ---------------------------------------------------------------------------
export interface RenderHandlers {
render: (c: Context) => Promise<Response>;
renderStream: (c: Context) => Response | Promise<Response>;
lint: (c: Context) => Promise<Response>;
health: (c: Context) => Response;
outputs: (c: Context) => Response;
queue: (c: Context) => Response;
}
/**
* Create route handler functions for the producer server.
*
* These can be mounted on any Hono app at any path prefix.
*/
export function createRenderHandlers(options: HandlerOptions = {}): RenderHandlers {
const log = options.logger ?? defaultLogger;
const getRequestId =
options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID());
const outputUrlPrefix = options.outputUrlPrefix ?? "/outputs";
const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
const artifactTtlMs =
options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000);
const store = createArtifactStore(artifactTtlMs);
const maxConcurrentRenders =
options.maxConcurrentRenders ?? Number(process.env.PRODUCER_MAX_CONCURRENT_RENDERS || 2);
const renderSemaphore = new Semaphore(maxConcurrentRenders);
const startTime = Date.now();
const health = (c: Context): Response =>
c.json({
status: "ok",
uptime: Math.floor((Date.now() - startTime) / 1000),
timestamp: new Date().toISOString(),
});
const lint = async (c: Context): Promise<Response> => {
const requestId = getRequestId(c);
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
}
const preparedResult = prepareHyperframeLintBody(body);
if ("error" in preparedResult) {
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
}
const result = await runHyperframeLint(preparedResult.prepared);
log.info("lint completed", {
requestId,
entryFile: preparedResult.prepared.entryFile,
source: preparedResult.prepared.source,
errorCount: result.errorCount,
warningCount: result.warningCount,
});
return c.json({
success: true,
requestId,
entryFile: preparedResult.prepared.entryFile,
source: preparedResult.prepared.source,
result,
});
};
const render = async (c: Context): Promise<Response> => {
const requestId = getRequestId(c);
const t0 = Date.now();
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
}
const preparedResult = await prepareRenderBody(body);
if ("error" in preparedResult) {
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
}
const { input, cleanupProjectDir, absoluteOutputPath } = resolvePreparedRenderOutput(
preparedResult.prepared,
rendersDir,
log,
);
const release = await renderSemaphore.acquire();
log.info("render started", {
requestId,
projectDir: input.projectDir,
fps: input.fps,
quality: input.quality,
});
const job = createRenderJob(buildRenderJobConfig(input, absoluteOutputPath, log));
try {
await executeRenderJob(
job,
input.projectDir,
absoluteOutputPath,
createBlockingProgressReporter(log, requestId),
);
const fileSize = outputFileSize(absoluteOutputPath);
const durationMs = Date.now() - t0;
const outputToken = store.register(absoluteOutputPath);
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
log.info("render completed", {
requestId,
durationMs,
fileSize,
perf: job.perfSummary ?? null,
});
return c.json({
success: true,
requestId,
outputPath: absoluteOutputPath,
outputToken,
outputUrl,
fileSize,
durationMs,
videoDurationSeconds: job.duration ?? null,
outcome: job.outcome ?? "completed",
warnings: job.warnings,
perf: job.perfSummary ?? null,
});
} catch (error) {
const durationMs = Date.now() - t0;
const errorMsg = error instanceof Error ? error.message : String(error);
const safeError = extractSafeRenderErrorMetadata(error);
log.error("render failed", {
requestId,
durationMs,
error: errorMsg,
stage: job.currentStage,
});
return c.json(
{
success: false,
requestId,
error: errorMsg,
errorCode: safeError?.errorCode,
errorOwner: safeError?.errorOwner,
retryable: safeError?.retryable,
stage: job.currentStage,
durationMs,
errorDetails: job.errorDetails ?? null,
},
500,
);
} finally {
release();
cleanupTempDir(cleanupProjectDir, log);
}
};
const renderStream = (c: Context) => {
return streamSSE(c, async (stream) => {
const requestId = getRequestId(c);
const t0 = Date.now();
const prepared = await prepareSseRenderRequest(c, stream, requestId);
if (!prepared) return;
const { input, cleanupProjectDir, absoluteOutputPath } = resolvePreparedRenderOutput(
prepared,
rendersDir,
log,
);
log.info("render-stream started", { requestId, projectDir: input.projectDir });
const job = createRenderJob(buildRenderJobConfig(input, absoluteOutputPath, log));
const abortController = new AbortController();
const onRequestAbort = () =>
abortController.abort(new RenderCancelledError("request_aborted"));
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
if (renderSemaphore.activeCount >= maxConcurrentRenders) {
await stream.writeSSE({
data: JSON.stringify({
type: "queued",
requestId,
position: renderSemaphore.waitingCount,
}),
});
}
const release = await renderSemaphore.acquire();
try {
await executeRenderJob(
job,
input.projectDir,
absoluteOutputPath,
createSseProgressReporter(stream, requestId),
abortController.signal,
);
const fileSize = outputFileSize(absoluteOutputPath);
const outputToken = store.register(absoluteOutputPath);
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null });
await stream.writeSSE({
data: JSON.stringify({
type: "complete",
requestId,
outputPath: absoluteOutputPath,
outputToken,
outputUrl,
fileSize,
videoDurationSeconds: job.duration ?? null,
outcome: job.outcome ?? "completed",
warnings: job.warnings,
perf: job.perfSummary ?? null,
}),
});
} catch (error) {
await writeRenderStreamFailure({
error,
job,
stream,
requestId,
startedAtMs: t0,
log,
});
} finally {
release();
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
cleanupTempDir(cleanupProjectDir, log);
}
});
};
const outputs = (c: Context): Response => {
const token = c.req.param("token") ?? "";
const artifact = store.get(token);
if (!artifact) {
return c.json({ success: false, error: "Output artifact not found or expired" }, 404);
}
if (!existsSync(artifact.path)) {
store.delete(token);
return c.json({ success: false, error: "Output artifact file missing" }, 404);
}
const stats = statSync(artifact.path);
return new Response(createReadStream(artifact.path) as unknown as ReadableStream, {
headers: {
"content-type": "video/mp4",
"content-length": String(stats.size),
"cache-control": "no-store",
},
});
};
const queue = (c: Context): Response =>
c.json({
maxConcurrentRenders,
activeRenders: renderSemaphore.activeCount,
queuedRenders: renderSemaphore.waitingCount,
});
return { render, renderStream, lint, health, outputs, queue };
}
// ---------------------------------------------------------------------------
// Public app factory
// ---------------------------------------------------------------------------
/**
* Create a Hono app with clean public routes for OSS use.
*/
export function createProducerApp(options: HandlerOptions = {}): Hono {
const app = new Hono();
const handlers = createRenderHandlers(options);
app.get("/health", handlers.health);
app.post("/render", handlers.render);
app.post("/render/stream", handlers.renderStream);
app.get("/render/queue", handlers.queue);
app.post("/lint", handlers.lint);
app.get("/outputs/:token", handlers.outputs);
return app;
}
// ---------------------------------------------------------------------------
// Standalone server
// ---------------------------------------------------------------------------
/**
* Start the producer HTTP server with graceful shutdown.
*/
export function startServer(options: ServerOptions = {}) {
const port = options.port ?? parseInt(process.env.PRODUCER_PORT ?? "9847", 10);
const log = options.logger ?? defaultLogger;
const app = createProducerApp(options);
const server = serve({ fetch: app.fetch, port }, () => {
log.info(`Listening on http://localhost:${port}`);
});
// Disable timeouts for long renders
server.setTimeout(0);
(server as unknown as import("node:http").Server).requestTimeout = 0;
(server as unknown as import("node:http").Server).keepAliveTimeout = 0;
// Start the worker-thread health endpoint alongside the main listener.
// The main thread keeps serving /health on `port` for backwards
// compatibility; the worker thread additionally serves /health on
// PRODUCER_HEALTH_PORT (default 9848) so k8s liveness/readiness probes can
// migrate to a listener that doesn't share an event loop with renders.
//
// Opt-out: set PRODUCER_DISABLE_HEALTH_WORKER=1 (e.g. for tests that don't
// want a worker spawned, or for environments where the extra port isn't
// wanted).
//
// We store the *promise* (not the resolved handle) so a SIGTERM that
// arrives before the worker has finished booting still has something to
// await. Awaiting a `let healthWorker = null` mutated from inside `.then`
// would race: if SIGTERM lands before the `.then` callback fires,
// `shutdown()` sees `null` and skips worker cleanup. The promise pattern
// closes that window without making startup blocking.
const healthWorkerPromise: Promise<HealthWorkerHandle | null> =
process.env.PRODUCER_DISABLE_HEALTH_WORKER === "1"
? Promise.resolve(null)
: startHealthWorker({ logger: log }).catch((err: Error) => {
// Don't crash the producer if the worker fails to start — the main
// /health is still up. Log loudly so the operator notices.
log.error(`[server] health worker failed to start: ${err.message}`);
return null;
});
async function shutdown(signal: string) {
log.info(`Received ${signal}, shutting down`);
const { drainBrowserPool } = await import("@hyperframes/engine");
await drainBrowserPool().catch(() => {});
// Bounded await: if the worker hasn't come online within 1.5s of
// shutdown there's no useful cleanup left to do — `worker.terminate()`
// from process exit will kill the thread regardless, and we'd rather
// not let a hung-startup worker keep the SIGTERM path waiting.
const handle = await Promise.race<HealthWorkerHandle | null>([
healthWorkerPromise,
new Promise<null>((res) => setTimeout(() => res(null), 1_500).unref()),
]);
if (handle) {
await handle.shutdown().catch(() => {});
}
server.close(() => {
log.info("Server closed");
process.exit(0);
});
setTimeout(() => {
log.warn("Forced exit after 30s timeout");
process.exit(1);
}, 30_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
return server;
}
// ---------------------------------------------------------------------------
// Self-executable: node dist/public-server.js
// ---------------------------------------------------------------------------
// Only auto-start when this file is the explicit entry point.
// In esbuild bundles, import.meta.url is shared across inlined modules,
// so we check argv[1] against known public server filenames.
const entryScript = process.argv[1] ? resolve(process.argv[1]) : "";
const isPublicServerEntry =
entryScript.endsWith("/public-server.js") || entryScript.endsWith("/src/server.ts");
if (isPublicServerEntry) {
const { values } = parseArgs({
options: {
port: { type: "string", short: "p", default: process.env.PRODUCER_PORT ?? "9847" },
},
});
startServer({ port: parseInt(values.port as string, 10) });
}