Files
hyperframes/packages/producer/build.mjs
T
James RussoandJerrai 01a10cdc53 fix(producer): serve /health from a worker_thread so probes survive main-thread stalls (#1733)
* fix(producer): serve /health from a worker_thread so probes survive main-thread stalls

Adds an off-main-thread /health endpoint that listens on its own port
(default 9848, env PRODUCER_HEALTH_PORT). The endpoint binds inside a
Node worker_thread with a minimal node:http server — separate event loop,
separate isolate — so probe responses don't depend on whatever the
producer's main thread is doing.

Why now
-------

Today's hyperframes-producer crashloop traced to an infinite GSAP
timeline -> distributed planner trying to enumerate ~300,000,000,000
frames -> sidecar /health stops landing within k8s's 5s window ->
otherwise-healthy pods killed.

Miguel is shipping the root-cause fix at plan() time (impossible /
non-finite / sentinel durations get rejected before chunk planning).
That removes today's wedge.

This change is defense-in-depth for the kill mechanism. Even with the
plan() guard, future wedge classes can stall the main event loop for
seconds at a time: large synchronous file I/O (see the companion
fileServer streaming PR), GC pauses on long-running renders, tight
loops in user-authored GSAP / Three.js / canvas code, future
activity / pool changes whose runtime cost we haven't yet characterized.

Probe responsiveness should reflect process liveness, not main-thread
event-loop responsiveness. If the entire Node process is dead the OS
tears down both threads' sockets simultaneously and k8s correctly kills
the pod. Anything short of that and the worker thread's listener keeps
answering.

Backwards-compatible: the main-thread /health on PRODUCER_PORT (9847)
keeps working exactly as before. The k8s sidecar probe config in
heygen-com/app can migrate to the worker port at its own pace. A
companion heygen-com/app PR in this batch raises the probe timeout
from 5s -> 30s as a last-resort backstop.

TODO: link Miguel's upstream plan() duration guard PR once known.

Test: healthWorker.test.ts (vitest) — 3 tests pass locally, including
the load-bearing one: stays responsive while the main thread is blocked
on a 500ms sync busy-spin.

— Jerrai

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

* fix(producer): tighten healthWorker startup race + shutdown semantics

Addresses Miga's review on #1733.

- server.ts: store the worker as a Promise<HealthWorkerHandle | null>
  instead of mutating a `let` from inside `.then`. A SIGTERM landing
  before the `.then` callback fired would previously see `healthWorker
  === null` and skip cleanup. shutdown() now `await`s the promise with
  a bounded 1.5s timeout so a hung-startup worker can't keep SIGTERM
  waiting (worker.terminate() from process exit still kills it).
- healthWorkerThread.ts: replace `process.exit()` inside the worker
  with `parentPort.close()` + natural event-loop drain. Node-version
  semantics for `process.exit()` from a worker have been historically
  inconsistent; the documented clean path is to close the channel and
  let the worker exit naturally. Also drops the redundant 2s force-exit
  on shutdown — the parent already owns the authoritative deadline via
  Promise.race + worker.terminate(), so the worker-side timer was
  belt-and-suspenders noise.

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

— Jerrai

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 08:47:49 -07:00

104 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Build script for @hyperframes/producer (public OSS package)
*
* Bundles src/server.ts → dist/public-server.js (standalone server).
*/
import { build } from "esbuild";
import { mkdirSync, rmSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
rmSync("dist", { recursive: true, force: true });
mkdirSync("dist", { recursive: true });
const scriptDir = dirname(fileURLToPath(import.meta.url));
// The banner provides a real `require` (via createRequire) plus the CJS-only
// `__filename`/`__dirname` globals so esbuild's CJS interop works in ESM output.
// Without `require`, bundled CJS deps (recast, yauzl, etc.) that call
// require("fs") throw "Dynamic require of 'fs' is not supported"; without the
// dirname shims, deps like wawoff2 throw "__dirname is not defined in ES module".
const cjsBanner = {
js: `import { createRequire as __cjsRequire } from 'module';
import { fileURLToPath as __cjsFileURLToPath } from 'url';
import { dirname as __cjsDirname } from 'path';
const require = __cjsRequire(import.meta.url);
const __filename = __cjsFileURLToPath(import.meta.url);
const __dirname = __cjsDirname(__filename);`,
};
const workspaceAliasPlugin = {
name: "workspace-alias",
setup(build) {
build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
path: resolve(scriptDir, "../engine/src/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
path: resolve(scriptDir, "../engine/src/utils/alphaBlit.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
path: resolve(scriptDir, "../engine/src/utils/shaderTransitions.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
path: resolve(scriptDir, "../core/src/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
path: resolve(scriptDir, "../core/src/lint/index.ts"),
}));
},
};
const sharedOpts = {
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
banner: cjsBanner,
};
await Promise.all([
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }),
build({
...sharedOpts,
entryPoints: ["src/services/shaderTransitionWorker.ts"],
outfile: "dist/services/shaderTransitionWorker.js",
}),
build({
...sharedOpts,
entryPoints: ["src/services/healthWorkerThread.ts"],
outfile: "dist/services/healthWorkerThread.js",
}),
build({ ...sharedOpts, entryPoints: ["src/distributed.ts"], outfile: "dist/distributed.js" }),
]);
// Copy core runtime artifacts so the producer can find them at dist/
import { copyFileSync, existsSync, readFileSync } from "fs";
const coreDistDir = resolve(scriptDir, "../core/dist");
try {
const manifestSrc = resolve(coreDistDir, "hyperframe.manifest.json");
if (existsSync(manifestSrc)) {
copyFileSync(manifestSrc, "dist/hyperframe.manifest.json");
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
const runtimeIife = manifest?.artifacts?.iife || "hyperframe.runtime.iife.js";
copyFileSync(resolve(coreDistDir, runtimeIife), `dist/${runtimeIife}`);
console.log(`[Build] Copied runtime: hyperframe.manifest.json, ${runtimeIife}`);
}
} catch (e) {
console.warn("[Build] Warning: Could not copy runtime artifacts:", e.message);
}
// Generate .d.ts declarations (esbuild doesn't emit them)
import { execSync } from "child_process";
execSync("tsc --emitDeclarationOnly --declaration --declarationMap", {
stdio: "inherit",
});
console.log("[Build] Complete: dist/index.js, dist/public-server.js, *.d.ts");