/** * File Server for Render Mode * * Lightweight HTTP server that serves the project directory inside Docker. * Key responsibility: inject the verified Hyperframe runtime + render mode extension * into index.html on-the-fly, so Puppeteer can load the composition with * all relative URLs (compositions, CSS, JS, assets) resolving correctly. */ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import type { IncomingMessage } from "node:http"; import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; import { join, extname, resolve, sep } from "node:path"; import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; export { injectScriptsAtHeadStart, injectScriptsIntoHtml }; type PathModuleLike = { resolve: (...segments: string[]) => string; sep: string; }; type IsPathInsideOptions = { resolveSymlinks?: boolean; /** * Path module used for resolution and separator comparison. Defaults to * `node:path` for the running platform. Tests inject `path.win32` / * `path.posix` to exercise cross-platform behavior on a single OS. */ pathModule?: PathModuleLike; }; /** * Returns true iff `child` is the same as, or nested inside, `parent` after * path normalization. Used to reject path-traversal attempts (e.g. * GET `/../etc/passwd`) before opening any file. * * `path.join(root, "..")` normalizes traversal segments and can escape `root` * entirely, so the join return value alone is not a safe guard. Callers must * resolve both sides and compare prefixes with the platform separator * appended to `parent` to avoid `/foo` matching `/foobar`. * * Exported for unit tests; not part of the public package surface. */ export function isPathInside( child: string, parent: string, options: IsPathInsideOptions = {}, ): boolean { const { resolveSymlinks = false, pathModule } = options; const resolveFn = pathModule?.resolve ?? resolve; const separator = pathModule?.sep ?? sep; const resolvedChild = resolveFn(child); const resolvedParent = resolveFn(parent); const normalizedChild = resolveSymlinks && existsSync(resolvedChild) ? realpathSync.native(resolvedChild) : resolvedChild; const normalizedParent = resolveSymlinks && existsSync(resolvedParent) ? realpathSync.native(resolvedParent) : resolvedParent; if (normalizedChild === normalizedParent) return true; const parentWithSep = normalizedParent.endsWith(separator) ? normalizedParent : normalizedParent + separator; return normalizedChild.startsWith(parentWithSep); } const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".aac": "audio/aac", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", }; /** * Options for {@link buildVirtualTimeShim}. */ export interface VirtualTimeShimOptions { /** * When `true`, the shim additionally replaces `Math.random` and * `crypto.getRandomValues` with a Mulberry32-seeded PRNG keyed by the * current frame's virtual time. Compositions that call `Math.random()` * during render then produce byte-identical pixels across machines and * across replays of the same `(planDir, chunkIndex)` pair. * * Default `false`: leaves `Math.random` / `crypto.getRandomValues` native, * preserving the in-process renderer's non-deterministic behavior for * compositions that rely on it. */ seedRandomFromFrame: boolean; } /** * Build the page-side virtual-time shim script. * * The shim freezes `Date.now`, `performance.now`, and the rAF/setTimeout * pipeline so a render seek can deterministically advance the page's * notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)` * before every frame capture; everything timing-related on the page sees * exactly `ms` until the next seek. * * When `options.seedRandomFromFrame` is `true`, the returned script also * installs a seeded `Math.random` / `crypto.getRandomValues` keyed by the * current virtual time — so compositions with stochastic visuals retry * identically. When `false`, the shim emits no random-override code; the * page's native `Math.random` is left alone (the in-process default). */ export function buildVirtualTimeShim(options: VirtualTimeShimOptions): string { const seedRandomFromFrame = options.seedRandomFromFrame === true; // The seeded-RNG block is gated at build time so the unlocked shim is // byte-identical to the pre-flag form. Producer regression baselines // compare on rendered pixels — but the file-server unit tests in // `fileServer.test.ts` also string-match `VIRTUAL_TIME_SHIM`, and we want // those matches to remain stable. const seededRandomBlock = seedRandomFromFrame ? String.raw` // Seeded Math.random / crypto.getRandomValues, keyed by virtual time. // Mulberry32 — single uint32 state, deterministic, fast. var rngState = 0; function mulberry32() { rngState |= 0; rngState = (rngState + 0x6D2B79F5) | 0; var t = rngState; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } function reseedRngFromTime(ms) { var ms32 = Math.max(0, Math.floor(Number(ms) || 0)) | 0; // Knuth's multiplicative hash + golden-ratio offset — gives a well- // distributed seed even for frame 0 (otherwise rngState=0 degenerates // the PRNG's first few outputs). rngState = (Math.imul(ms32, -1640531527) + 0x9E3779B9) | 0; } reseedRngFromTime(0); try { Math.random = function() { return mulberry32(); }; } catch (e) {} if (window.crypto && typeof window.crypto.getRandomValues === "function") { try { var __seededGetRandomValues = function(arr) { if (!arr || typeof arr.byteLength !== "number" || !arr.buffer) return arr; var byteLen = arr.byteLength; if (byteLen <= 0) return arr; var view = new DataView(arr.buffer, arr.byteOffset, byteLen); var i = 0; for (; i + 4 <= byteLen; i += 4) { var word = ((mulberry32() * 4294967296) >>> 0); view.setUint32(i, word, true); } for (; i < byteLen; i++) { view.setUint8(i, (mulberry32() * 256) | 0); } return arr; }; window.crypto.getRandomValues = __seededGetRandomValues; } catch (e) {} } ` : ""; // The seekToTime hook reseeds when seeding is on; under seedRandomFromFrame=false // we emit no extra call so the function body is byte-identical to the // unseeded shim. const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : ""; return String.raw`(function() { if (window.__HF_VIRTUAL_TIME__) return; var virtualNowMs = 0; var rafId = 1; var rafQueue = []; var OriginalDate = Date; var originalSetTimeout = window.setTimeout.bind(window); var originalClearTimeout = window.clearTimeout.bind(window); var originalSetInterval = window.setInterval.bind(window); var originalClearInterval = window.clearInterval.bind(window); var originalRequestAnimationFrame = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null; var originalCancelAnimationFrame = window.cancelAnimationFrame ? window.cancelAnimationFrame.bind(window) : null; ${seededRandomBlock} function flushAnimationFrame() { if (!rafQueue.length) return; var current = rafQueue.slice(); rafQueue.length = 0; for (var i = 0; i < current.length; i++) { var entry = current[i]; if (entry.cancelled) continue; try { entry.callback(virtualNowMs); } catch {} } } function VirtualDate() { var args = Array.prototype.slice.call(arguments); if (!(this instanceof VirtualDate)) { return OriginalDate.apply(null, args.length ? args : [virtualNowMs]); } var instance = args.length ? new (Function.prototype.bind.apply(OriginalDate, [null].concat(args)))() : new OriginalDate(virtualNowMs); Object.setPrototypeOf(instance, VirtualDate.prototype); return instance; } VirtualDate.prototype = OriginalDate.prototype; Object.setPrototypeOf(VirtualDate, OriginalDate); VirtualDate.now = function() { return virtualNowMs; }; VirtualDate.parse = OriginalDate.parse.bind(OriginalDate); VirtualDate.UTC = OriginalDate.UTC.bind(OriginalDate); try { Object.defineProperty(window, "Date", { configurable: true, writable: true, value: VirtualDate, }); } catch {} if (window.performance && typeof window.performance.now === "function") { try { Object.defineProperty(window.performance, "now", { configurable: true, value: function() { return virtualNowMs; }, }); } catch {} } window.requestAnimationFrame = function(callback) { if (typeof callback !== "function") return 0; var entry = { id: rafId++, callback: callback, cancelled: false }; rafQueue.push(entry); return entry.id; }; window.cancelAnimationFrame = function(id) { for (var i = 0; i < rafQueue.length; i++) { if (rafQueue[i].id === id) { rafQueue[i].cancelled = true; } } }; window.__HF_VIRTUAL_TIME__ = { originalSetTimeout: originalSetTimeout, originalClearTimeout: originalClearTimeout, originalSetInterval: originalSetInterval, originalClearInterval: originalClearInterval, originalRequestAnimationFrame: originalRequestAnimationFrame, originalCancelAnimationFrame: originalCancelAnimationFrame, seekToTime: function(nextTimeMs) { var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0); virtualNowMs = safeTimeMs; ${seekToTimeReseedCall}flushAnimationFrame(); return virtualNowMs; }, getTime: function() { return virtualNowMs; }, }; })();`; } /** * Default in-process virtual-time shim — `seedRandomFromFrame: false`. * Existing call sites (`renderOrchestrator`, `probeStage`) import this * constant. Distributed callers build their own with seeding enabled. */ const VIRTUAL_TIME_SHIM = buildVirtualTimeShim({ seedRandomFromFrame: false }); /** * Render mode extension -- adds renderSeek() for frame-accurate seeking * without media sync (videos are replaced with frame images during render). */ const RENDER_SEEK_MODE = process.env.PRODUCER_RUNTIME_RENDER_SEEK_MODE === "strict-boundary" ? "strict-boundary" : "preview-phase"; const RENDER_SEEK_DIAGNOSTICS = process.env.PRODUCER_DEBUG_SEEK_DIAGNOSTICS === "true"; const RENDER_SEEK_STEP = Math.max( 1 / 600, Number(process.env.PRODUCER_RENDER_SEEK_STEP || 1 / 120), ); const RENDER_SEEK_OFFSET_FRACTION = Math.max( 0, Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)), ); const RENDER_MODE_SCRIPT = `(function() { var __realSetTimeout = window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function" ? window.__HF_VIRTUAL_TIME__.originalSetTimeout : window.setTimeout.bind(window); var __seekMode = ${JSON.stringify(RENDER_SEEK_MODE)}; var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"}; var __seekStep = ${RENDER_SEEK_STEP}; var __seekOffsetFraction = ${RENDER_SEEK_OFFSET_FRACTION}; window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: __seekMode, diagnostics: __seekDiagnostics, step: __seekStep, offsetFraction: __seekOffsetFraction, owner: "runtime", }; function installMediaFallbackPlayer() { if (document.querySelector('[data-composition-id]')) return false; var mediaEls = Array.from(document.querySelectorAll('video, audio')); if (!mediaEls.length) return false; var isPlaying = false; var currentTime = 0; function fallbackDuration() { var maxDuration = 0; for (var i = 0; i < mediaEls.length; i++) { var d = Number(mediaEls[i].duration); if (isFinite(d) && d > maxDuration) maxDuration = d; } return Math.max(0, maxDuration); } function syncFallbackMedia(time, playing) { for (var i = 0; i < mediaEls.length; i++) { var media = mediaEls[i]; var existing = Number(media.currentTime) || 0; if (Math.abs(existing - time) > 0.3) { try { media.currentTime = time; } catch (e) {} } if (playing) { if (media.paused) { media.play().catch(function() {}); } } else if (!media.paused) { media.pause(); } } } var basePlayer = window.__player && typeof window.__player === 'object' ? window.__player : {}; window.__player = { ...basePlayer, _timeline: null, play: function() { isPlaying = true; syncFallbackMedia(currentTime, true); }, pause: function() { isPlaying = false; syncFallbackMedia(currentTime, false); }, seek: function(time) { var safeTime = Math.max(0, Number(time) || 0); currentTime = safeTime; isPlaying = false; syncFallbackMedia(safeTime, false); }, renderSeek: function(time) { var safeTime = Math.max(0, Number(time) || 0); currentTime = safeTime; isPlaying = false; syncFallbackMedia(safeTime, false); }, getTime: function() { var primary = mediaEls[0]; if (!primary) return currentTime; var t = Number(primary.currentTime); return isFinite(t) ? t : currentTime; }, getDuration: function() { return fallbackDuration(); }, isPlaying: function() { return isPlaying; }, }; window.__playerReady = true; window.__renderReady = true; return true; } function waitForPlayer() { var hasComposition = Boolean(document.querySelector('[data-composition-id]')); if (hasComposition) { if (window.__player && typeof window.__player.renderSeek === "function") { window.__playerReady = true; window.__renderReady = true; return; } __realSetTimeout(waitForPlayer, 50); return; } if (installMediaFallbackPlayer()) { return; } __realSetTimeout(waitForPlayer, 50); } waitForPlayer(); })();`; /** * Early stub: ensures `window.__hf` exists *before* any user `