mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
Part of Phase 2 of the distributed rendering plan (determinism hardening). See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and §5.2 (RENDER_SEEK_MODE row). `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*` env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP, RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that may inherit a different environment, so the plan needs to freeze a snapshot. Adds `snapshotRuntimeEnv(env = process.env)` in packages/producer/src/services/render/stages/freezePlan.ts. Captures keys matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh plain object, ignoring everything else. Phase 3's `renderChunk` will materialize the snapshot back into `process.env` before launching its file server. Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can apply the same prefix filter (asymmetric handling would leak stale controller env into worker behavior). The freezePlan function body remains a skeleton — Phase 3 owns the full implementation. The snapshot helper is exported on its own so this gate's unit test can pin the behavior without depending on the not-yet-written freezePlan body. In-process behavior is unchanged: no in-process caller invokes freezePlan or snapshotRuntimeEnv yet. 9 unit tests at packages/producer/src/services/render/stages/ freezePlan.test.ts cover: prefix matches (both families), non-matching keys ignored, undefined values skipped, fresh-object contract, and default-to-process.env behavior. This is part of a stack of 10 PRs; this is PR 5 of 10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Tests for the `snapshotRuntimeEnv` helper that backs the
|
||||
* `LockedRenderConfig.runtimeEnv` field.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { RUNTIME_ENV_PREFIXES, snapshotRuntimeEnv } from "./runtimeEnvSnapshot.js";
|
||||
|
||||
describe("snapshotRuntimeEnv", () => {
|
||||
it("captures PRODUCER_RUNTIME_* keys", () => {
|
||||
const env = {
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
|
||||
};
|
||||
expect(snapshotRuntimeEnv(env)).toEqual({
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("captures PRODUCER_RENDER_* keys", () => {
|
||||
const env = {
|
||||
PRODUCER_RENDER_SEEK_STEP: "0.008",
|
||||
};
|
||||
expect(snapshotRuntimeEnv(env)).toEqual({
|
||||
PRODUCER_RENDER_SEEK_STEP: "0.008",
|
||||
});
|
||||
});
|
||||
|
||||
it("captures both prefix families in a single snapshot", () => {
|
||||
const env = {
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
PRODUCER_RENDER_SEEK_STEP: "0.008",
|
||||
};
|
||||
expect(snapshotRuntimeEnv(env)).toEqual({
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
PRODUCER_RENDER_SEEK_STEP: "0.008",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores keys that don't match either prefix", () => {
|
||||
const env = {
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
HOME: "/home/ci",
|
||||
PATH: "/usr/bin:/bin",
|
||||
NODE_ENV: "production",
|
||||
// Off-by-one prefix variants — must NOT be captured.
|
||||
PRODUCER_RUNTIM_FOO: "x",
|
||||
PRODUCER_RENDR_BAR: "y",
|
||||
PRODUCER_DEBUG_SEEK_DIAGNOSTICS: "true",
|
||||
};
|
||||
const snapshot = snapshotRuntimeEnv(env);
|
||||
expect(snapshot).toEqual({
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
});
|
||||
expect(Object.keys(snapshot)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips keys whose value is undefined", () => {
|
||||
const env = {
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
PRODUCER_RUNTIME_OTHER: undefined,
|
||||
};
|
||||
expect(snapshotRuntimeEnv(env)).toEqual({
|
||||
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty object when no keys match", () => {
|
||||
expect(snapshotRuntimeEnv({ HOME: "/home/ci", PATH: "/usr/bin" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a NEW object each call (no live reference to process.env)", () => {
|
||||
const env = { PRODUCER_RUNTIME_X: "v1" };
|
||||
const first = snapshotRuntimeEnv(env);
|
||||
env.PRODUCER_RUNTIME_X = "v2";
|
||||
const second = snapshotRuntimeEnv(env);
|
||||
expect(first.PRODUCER_RUNTIME_X).toBe("v1");
|
||||
expect(second.PRODUCER_RUNTIME_X).toBe("v2");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("defaults to process.env when no argument is passed", () => {
|
||||
const key = `PRODUCER_RUNTIME_FREEZEPLAN_TEST_${Date.now()}`;
|
||||
const sentinel = "freezeplan-test-value";
|
||||
process.env[key] = sentinel;
|
||||
try {
|
||||
const snapshot = snapshotRuntimeEnv();
|
||||
expect(snapshot[key]).toBe(sentinel);
|
||||
} finally {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("exports the prefix list for chunk-worker materialization", () => {
|
||||
// Chunk workers must apply the SAME prefix filter when reading the
|
||||
// snapshot back; asymmetric handling would let stale controller env
|
||||
// leak into chunk-worker behavior.
|
||||
expect(RUNTIME_ENV_PREFIXES).toEqual(["PRODUCER_RUNTIME_", "PRODUCER_RENDER_"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* runtimeEnvSnapshot — capture / re-apply the env vars that drive in-page
|
||||
* render behavior.
|
||||
*
|
||||
* `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
|
||||
* variables at module-load time and bakes them into the served HTML's
|
||||
* `RENDER_MODE_SCRIPT`. Distributed chunk workers are separate processes
|
||||
* that may inherit a different environment, so the plan freezes a snapshot
|
||||
* of the controller's env. The chunk worker then materializes the snapshot
|
||||
* back into `process.env` before launching its file server, which keeps the
|
||||
* served HTML byte-identical to what the controller would have served.
|
||||
*
|
||||
* Used by `freezePlan` (capture side) and the chunked render worker
|
||||
* (re-apply side). Kept here as a standalone utility because it has no
|
||||
* dependency on the plan-freeze pipeline.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Env-var name prefixes captured by {@link snapshotRuntimeEnv}. Exported so
|
||||
* the chunk-worker side can apply the same filter when materializing a
|
||||
* snapshot — asymmetric handling would leak stale controller env into
|
||||
* worker behavior.
|
||||
*/
|
||||
export const RUNTIME_ENV_PREFIXES: readonly string[] = [
|
||||
"PRODUCER_RUNTIME_",
|
||||
"PRODUCER_RENDER_",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Snapshot `process.env` keys that match any of {@link RUNTIME_ENV_PREFIXES}
|
||||
* into a plain string→string record. Returns a NEW object each call (never a
|
||||
* live reference to `process.env`) so subsequent mutations of the process
|
||||
* env do not retroactively change a frozen plan.
|
||||
*
|
||||
* Pass an optional `env` for tests that don't want to mutate the real
|
||||
* process env. The default reads `process.env`.
|
||||
*/
|
||||
export function snapshotRuntimeEnv(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Record<string, string> {
|
||||
const snapshot: Record<string, string> = {};
|
||||
for (const key of Object.keys(env)) {
|
||||
const matches = RUNTIME_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
if (!matches) continue;
|
||||
const value = env[key];
|
||||
// Skip undefined / non-string values. `process.env` only ever returns
|
||||
// strings, but `Record<string, string | undefined>` lets tests pass an
|
||||
// env object with explicit `undefined` slots (e.g. after `delete`).
|
||||
if (typeof value !== "string") continue;
|
||||
snapshot[key] = value;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -89,13 +89,23 @@ export interface FreezePlanResult {
|
||||
planHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export the runtime-env snapshot helper for backward compatibility with
|
||||
* earlier imports from `./freezePlan`. The implementation lives in
|
||||
* `../runtimeEnvSnapshot.ts` — chunk workers re-apply the snapshot during
|
||||
* boot, so it needs to be importable without dragging in the freeze pipeline.
|
||||
*/
|
||||
export { snapshotRuntimeEnv, RUNTIME_ENV_PREFIXES } from "../runtimeEnvSnapshot.js";
|
||||
|
||||
/**
|
||||
* Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
|
||||
* compute `planHash` over the canonicalized contents.
|
||||
*
|
||||
* Skeleton — body lands when the distributed-render primitives compose the
|
||||
* stage functions.
|
||||
* stage functions. The body will resolve `input.encoder.runtimeEnv ||=
|
||||
* snapshotRuntimeEnv()` so callers can optionally pre-populate the field,
|
||||
* with the live env as the default.
|
||||
*/
|
||||
export async function freezePlan(_input: FreezePlanInput): Promise<FreezePlanResult> {
|
||||
throw new Error("freezePlan is not implemented yet — see DISTRIBUTED-RENDERING-PLAN.md §11.");
|
||||
throw new Error("freezePlan is not implemented yet.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user