#!/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, } from "./services/renderOrchestrator.js"; import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js"; import { resolveRenderPaths } from "./utils/paths.js"; import { defaultLogger, type ProducerLogger } from "./logger.js"; import { Semaphore } from "./utils/semaphore.js"; import { parseFps } from "@hyperframes/core"; // --------------------------------------------------------------------------- // 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"; workers?: number; useGpu: boolean; debug: boolean; entryFile?: string; } interface PreparedRenderInput { input: RenderInput; cleanupProjectDir?: string; } function parseRenderOptions(body: Record): Omit { // 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 fpsRaw = body.fps; const fpsParse = typeof fpsRaw === "number" || typeof fpsRaw === "string" ? parseFps(fpsRaw) : null; const fps = fpsParse && fpsParse.ok ? fpsParse.value : ({ num: 30, den: 1 } as const); const quality = ( ["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high" ) as "draft" | "standard" | "high"; const workers = typeof body.workers === "number" ? body.workers : undefined; const useGpu = body.gpu === true; const debug = body.debug === true; const outputPath = typeof body.outputPath === "string" && body.outputPath.trim().length > 0 ? body.outputPath : typeof body.output === "string" && body.output.trim().length > 0 ? body.output : null; const entryFile = typeof body.entryFile === "string" && body.entryFile.trim().length > 0 ? body.entryFile.trim() : undefined; const format = ( ["mp4", "webm", "mov"].includes(body.format as string) ? body.format : undefined ) as "mp4" | "webm" | "mov" | undefined; return { outputPath, fps, quality, workers, useGpu, debug, entryFile, format }; } async function prepareRenderBody( body: Record, ): Promise<{ prepared: PreparedRenderInput } | { error: string }> { const options = parseRenderOptions(body); const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined; if (projectDir) { const absProjectDir = resolve(projectDir); 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 } } }; } const previewUrl = typeof body.previewUrl === "string" ? body.previewUrl.trim() : ""; const inlineHtml = typeof body.html === "string" ? body.html : ""; if (!previewUrl && !inlineHtml) { return { error: "Missing render source: provide projectDir, previewUrl, or html" }; } let htmlContent = inlineHtml; if (!htmlContent) { try { const response = await fetch(previewUrl, { method: "GET" }); if (!response.ok) { return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` }; } htmlContent = await response.text(); } catch (error) { return { error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`, }; } } const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir(); const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-")); writeFileSync(join(tempProjectDir, "index.html"), htmlContent, "utf-8"); return { prepared: { input: { projectDir: tempProjectDir, ...options, }, cleanupProjectDir: tempProjectDir, }, }; } 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(); 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), }); } } // --------------------------------------------------------------------------- // Handler factory // --------------------------------------------------------------------------- export interface RenderHandlers { render: (c: Context) => Promise; renderStream: (c: Context) => Response | Promise; lint: (c: Context) => Promise; 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 => { const requestId = getRequestId(c); let body: Record; 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 = 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 => { const requestId = getRequestId(c); const t0 = Date.now(); let body: Record; 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 } = preparedResult.prepared; const absoluteOutputPath = resolveOutputPath( input.projectDir, input.outputPath, rendersDir, log, ); const outputDir = dirname(absoluteOutputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); const release = await renderSemaphore.acquire(); log.info("render started", { requestId, projectDir: input.projectDir, fps: input.fps, quality: input.quality, }); const job = createRenderJob({ fps: input.fps, quality: input.quality, format: input.format, workers: input.workers, useGpu: input.useGpu, debug: input.debug, entryFile: input.entryFile, logger: log, }); let lastLoggedPct = -10; try { await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => { const pct = Math.floor(j.progress * 100); if (pct >= lastLoggedPct + 10) { lastLoggedPct = pct; log.info(`render progress ${pct}%`, { requestId, stage: j.currentStage, message }); } }); const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0; 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, perf: job.perfSummary ?? null, }); } catch (error) { const durationMs = Date.now() - t0; const errorMsg = error instanceof Error ? error.message : String(error); log.error("render failed", { requestId, durationMs, error: errorMsg, stage: job.currentStage, }); return c.json( { success: false, requestId, error: errorMsg, 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(); let body: Record; try { body = await c.req.json(); } catch { await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: "Invalid JSON body", stage: "validation", }), }); return; } const preparedResult = await prepareRenderBody(body); if ("error" in preparedResult) { await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: preparedResult.error, stage: "validation", }), }); return; } const { input, cleanupProjectDir } = preparedResult.prepared; const absoluteOutputPath = resolveOutputPath( input.projectDir, input.outputPath, rendersDir, log, ); const outputDir = dirname(absoluteOutputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); log.info("render-stream started", { requestId, projectDir: input.projectDir }); const job = createRenderJob({ fps: input.fps, quality: input.quality, format: input.format, workers: input.workers, useGpu: input.useGpu, debug: input.debug, entryFile: input.entryFile, logger: 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, async (j, message) => { await stream.writeSSE({ data: JSON.stringify({ type: "progress", requestId, stage: j.currentStage, progress: j.progress, framesRendered: j.framesRendered ?? 0, totalFrames: j.totalFrames ?? 0, message, }), }); }, abortController.signal, ); const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0; 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, perf: job.perfSummary ?? null, }), }); } catch (error) { if (error instanceof RenderCancelledError) { await stream.writeSSE({ data: JSON.stringify({ type: "cancelled", requestId, stage: job.currentStage, message: error.message, }), }); return; } const errorMsg = error instanceof Error ? error.message : String(error); const elapsedMs = Date.now() - t0; log.error("render-stream failed", { requestId, elapsedMs, error: errorMsg, stage: job.currentStage, }); await stream.writeSSE({ data: JSON.stringify({ type: "error", requestId, error: errorMsg, stage: job.currentStage, elapsedMs, errorDetails: job.errorDetails ?? null, }), }); } 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; async function shutdown(signal: string) { log.info(`Received ${signal}, shutting down`); const { drainBrowserPool } = await import("@hyperframes/engine"); await drainBrowserPool().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) }); }