feat(gcp-cloud-run): Google Cloud Run + Workflows distributed render adapter (#1253)

* feat(gcp-cloud-run): add Google Cloud Run + Workflows distributed render adapter

Adds @hyperframes/gcp-cloud-run, the GCP counterpart to @hyperframes/aws-lambda
(issue #932). The OSS distributed primitives (plan, renderChunk x N, assemble)
are unchanged; this package is the storage/compute/orchestration glue.

Package: Cloud Run handler (one image, three actions), runs under bun; GCS
transport; in-image chrome-headless-shell resolver; client SDK
(renderToCloudRun, getRenderProgress, deploySite, computeRenderCost); Dockerfile;
Cloud Workflows definition; Terraform module; CLI cloudrun
deploy|sites|render|render-batch|progress|destroy with --output-resolution and
--strict-variables; 62 unit tests + docs + live smoke script.

Shared extraction (removes ~640 lines of adapter duplication): move the
cloud-agnostic config validator + content-hash into producer/distributed; both
adapters import them. Validated end-to-end on GCP at 37.4 dB PSNR vs baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve @hyperframes/gcp-cloud-run in the CLI build + root build

The CLI bundle (esbuild) couldn't resolve `@hyperframes/gcp-cloud-run/sdk`,
failing Build/Typecheck/CLI-smoke (and the perf/windows/regression jobs that
build first). Mirror the aws-lambda handling: mark the gcp adapter + its /sdk
subpath external in tsup.config.ts with a source alias, and add gcp-cloud-run
to the root `build` filter so its dist exists for publish + runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): copy gcp-cloud-run manifest in Dockerfile.test for frozen install

The regression test image runs `bun install --frozen-lockfile` after copying
each workspace package.json individually. The CLI now depends on
@hyperframes/gcp-cloud-run (workspace:*), so the frozen install fails to
resolve it unless its manifest is present. Add the COPY line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): add machine-sizing flags to `cloudrun deploy`

Closes the parity gap with `lambda deploy` (which exposes --memory etc.).
`cloudrun deploy` now threads --cpu, --memory, --max-instances, and --timeout
into the Terraform apply; omitted flags keep the module defaults
(4 vCPU / 16Gi / 100 instances / 3600s). For finer control, apply the module
directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gcp-cloud-run): address PR review (security, waste, limits, alerts)

- server.ts: bucket-allowlist guard no longer fails open silently. Unset env
  logs a one-time WARNING; "*" is an explicit opt-out; otherwise it enforces.
- server.ts: stop double-shipping audio.aac. It already rides in the plan
  tarball every consumer downloads, so drop the redundant standalone upload
  (plan) + re-download/overwrite (assemble); assemble reads it from the untar,
  falling back to a supplied AudioGcsUri for compat.
- server.ts: chunk extension via path.extname() instead of slice(lastIndexOf).
- workflow.yaml: clamp parallel concurrency_limit to math.min(chunkCount, 20)
  — Cloud Workflows hard-caps concurrent iterations at 20.
- Dockerfile: pin bun (bun-v1.3.9) so an interop change can't silently break
  the image rebuild.
- terraform: add min_instances var (default 0); add a workflow-failure alert
  (finished_execution_count status=FAILED) alongside the request-count one.
- costAccounting: document that displayCost excludes GCS storage/egress.

Verified against the actual APIs: @google-cloud/workflows@4.4.0
ICreateExecutionRequest has no executionId (so the idempotency-token suggestion
isn't available in this client); Workflows concurrency cap is 20; failure
metric is workflows.googleapis.com/finished_execution_count (status label).
174 adapter tests pass, fallow/oxlint/oxfmt/terraform clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gcp-cloud-run): address round-2 review — error code + CFR forwarding

- workflow.yaml: rename the zero-chunk failure code PLAN_TOO_LARGE →
  PLAN_PRODUCED_ZERO_CHUNKS. The old code implied a size-ceiling breach (the
  opposite cause), misleading anyone triaging the alert.
- workflow.yaml: forward Config.cfr to the assemble step
  (`Cfr: ${("cfr" in config) and config.cfr}`). It was read by the handler
  but never sent, so exact-CFR was silently off for every Cloud Run render.
  Uses the same `in`-operator guard already proven in the retryable predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(release): include gcp-cloud-run in set-version PACKAGES list

set-version.ts (driven by release:prepare) bumps an explicit package list to
the shared version on each release. gcp-cloud-run was wired into the build +
publish.yml but missing here, so a release would leave it at a stale version
and publish.yml would push the wrong version. Add it so the new package
version-bumps + publishes in lockstep with the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-07 14:43:38 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 806b226b34
commit 4da567df22
60 changed files with 5782 additions and 362 deletions
@@ -0,0 +1,35 @@
/**
* `computeRenderCost` unit tests — Cloud Run vCPU/GiB-second + Workflows
* step math.
*/
import { describe, expect, it } from "bun:test";
import { type BilledCloudRunInvocation, computeRenderCost } from "./costAccounting.js";
describe("computeRenderCost", () => {
it("returns zero for no invocations", () => {
const cost = computeRenderCost([], 0);
expect(cost.accruedSoFarUsd).toBe(0);
expect(cost.displayCost).toBe("$0.0000");
});
it("sums vCPU + memory seconds plus per-request and step charges", () => {
const invs: BilledCloudRunInvocation[] = [
{ durationMs: 10_000, vcpu: 4, memoryGib: 16, estimated: false },
{ durationMs: 10_000, vcpu: 4, memoryGib: 16, estimated: false },
];
const cost = computeRenderCost(invs, 6);
// 2 × 10s: vCPU 80 vcpu-s × 0.000024 = 0.00192; mem 320 GiB-s × 0.0000025 = 0.0008;
// requests 2 × 4e-7 ≈ 0 → raw 0.0027208, rounded to 4 dp = 0.0027.
// workflows 6 × 1e-5 = 0.00006 → rounds up to 0.0001 at 4 dp.
expect(cost.breakdown.cloudRunUsd).toBeCloseTo(0.0027, 4);
expect(cost.breakdown.workflowsUsd).toBeCloseTo(0.0001, 4);
expect(cost.accruedSoFarUsd).toBeGreaterThan(0);
expect(cost.breakdown.gcsEstimate).toBe("not-included");
});
it("flags estimated when any invocation was estimated", () => {
const cost = computeRenderCost([{ durationMs: 0, vcpu: 4, memoryGib: 16, estimated: true }], 4);
expect(cost.breakdown.estimated).toBe(true);
});
});
@@ -0,0 +1,113 @@
/**
* Per-render cost accounting for {@link getRenderProgress}.
*
* Google bills the render service two ways:
*
* - **Cloud Run** by **vCPU-seconds** and **GiB-seconds** of request
* processing time, plus a flat per-request charge. Each handler
* invocation returns its own `DurationMs` in the result body, so the
* progress reader can recover billed time per step without a separate
* Cloud Monitoring query — multiply by the service's configured vCPU /
* memory to get the resource-seconds.
* - **Cloud Workflows** by **steps executed**. The orchestration is a
* fixed shape (Plan + N×RenderChunk + Assemble + a handful of control
* steps), so the step count scales with chunk count.
*
* The math is documented inline so the constants stay close to the pricing
* source they came from. Cost is **best-effort**: GCP pricing varies by
* region + committed-use discounts; we use on-demand `us-central1` (Tier 1)
* rates as of 2026-06 and label the result `displayCost` so callers see the
* dollar value but downstream automation can also read the raw number.
*/
/** Cloud Run request-based billing, us-central1 Tier 1: USD per vCPU-second. */
const CLOUD_RUN_USD_PER_VCPU_SECOND = 0.000024;
/** Cloud Run request-based billing, us-central1 Tier 1: USD per GiB-second. */
const CLOUD_RUN_USD_PER_GIB_SECOND = 0.0000025;
/** Cloud Run: USD per request ($0.40 per million). */
const CLOUD_RUN_USD_PER_REQUEST = 0.0000004;
/** Cloud Workflows: USD per internal step ($0.01 per 1,000, after a free tier). */
const WORKFLOWS_USD_PER_STEP = 0.00001;
/** Per-invocation billed slice the cost calc cares about. */
export interface BilledCloudRunInvocation {
/** Wall-clock the handler reported via `DurationMs` in its result body. */
durationMs: number;
/** vCPU the Cloud Run service was configured with at invocation time. */
vcpu: number;
/** Memory in GiB the Cloud Run service was configured with. */
memoryGib: number;
/** `true` if the duration was inferred (step result missing) rather than read from the handler payload. */
estimated: boolean;
}
/**
* Result of {@link computeRenderCost}.
*
* NOTE: `displayCost` / `accruedSoFarUsd` cover Cloud Run compute + Cloud
* Workflows steps only. They EXCLUDE GCS storage + network egress for the
* plan tarball (which can be ~100 MB), chunk artifacts, and the final output
* — see `breakdown.gcsEstimate`. Treat the figure as a compute-cost floor,
* not the authoritative total bill.
*/
export interface RenderCost {
/** USD accrued to date (Cloud Run + Workflows only; excludes GCS — see note above). */
accruedSoFarUsd: number;
/** Human-readable USD string, e.g. `"$0.0214"`. Excludes GCS storage/egress. */
displayCost: string;
breakdown: {
cloudRunUsd: number;
workflowsUsd: number;
/** GCS transfer + storage cost varies by tier; we don't try to compute it here. */
gcsEstimate: "not-included";
/** `true` if any invocation fell back to estimated billing. */
estimated: boolean;
};
}
/**
* Sum Cloud Run vCPU-seconds + GiB-seconds + per-request charges and Cloud
* Workflows steps into an aggregate USD figure.
*
* `workflowSteps` is the count of Workflows steps executed so far — Plan
* (1) + RenderChunk (chunkCount) + Assemble (1) + the control steps
* (BuildChunkList, AssertChunkCount, …). Pass the count the progress reader
* derived from the execution; a rough constant overhead is fine since the
* step charge is a rounding error next to Cloud Run compute.
*/
export function computeRenderCost(
invocations: BilledCloudRunInvocation[],
workflowSteps: number,
): RenderCost {
let cloudRunUsd = 0;
let anyEstimated = false;
for (const inv of invocations) {
const seconds = inv.durationMs / 1000;
cloudRunUsd += seconds * inv.vcpu * CLOUD_RUN_USD_PER_VCPU_SECOND;
cloudRunUsd += seconds * inv.memoryGib * CLOUD_RUN_USD_PER_GIB_SECOND;
cloudRunUsd += CLOUD_RUN_USD_PER_REQUEST;
if (inv.estimated) anyEstimated = true;
}
const workflowsUsd = workflowSteps * WORKFLOWS_USD_PER_STEP;
const accruedSoFarUsd = roundUsd(cloudRunUsd + workflowsUsd);
return {
accruedSoFarUsd,
displayCost: formatUsd(accruedSoFarUsd),
breakdown: {
cloudRunUsd: roundUsd(cloudRunUsd),
workflowsUsd: roundUsd(workflowsUsd),
gcsEstimate: "not-included",
estimated: anyEstimated,
},
};
}
function roundUsd(usd: number): number {
// Four decimal places — enough resolution for per-chunk granularity.
// Anything finer is noise vs GCP's own rounding.
return Math.round(usd * 10_000) / 10_000;
}
function formatUsd(usd: number): string {
return `$${usd.toFixed(4)}`;
}
@@ -0,0 +1,86 @@
/**
* `deploySite` unit tests — content-addressed siteId, existence
* short-circuit, and the upload path over `FakeGcs`.
*/
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { asStorage, FakeGcs } from "../__fixtures__/fakeGcs.js";
import { deploySite } from "./deploySite.js";
const tmpDirs: string[] = [];
function mkProject(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "hf-site-"));
tmpDirs.push(dir);
writeFileSync(join(dir, "index.html"), content);
return dir;
}
afterEach(() => {
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
describe("deploySite", () => {
it("uploads and returns a content-addressed handle", async () => {
const gcs = new FakeGcs();
const dir = mkProject("<html>v1</html>");
const handle = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) });
expect(handle.uploaded).toBe(true);
expect(handle.projectGcsUri).toBe(`gs://b/sites/${handle.siteId}/project.tar.gz`);
expect(gcs.objects.has(handle.projectGcsUri)).toBe(true);
});
it("produces a stable siteId for identical content", async () => {
const a = await deploySite({
projectDir: mkProject("<html>same</html>"),
bucketName: "b",
storage: asStorage(new FakeGcs()),
});
const b = await deploySite({
projectDir: mkProject("<html>same</html>"),
bucketName: "b",
storage: asStorage(new FakeGcs()),
});
expect(a.siteId).toBe(b.siteId);
});
it("produces different siteIds for different content", async () => {
const a = await deploySite({
projectDir: mkProject("<html>one</html>"),
bucketName: "b",
storage: asStorage(new FakeGcs()),
});
const b = await deploySite({
projectDir: mkProject("<html>two</html>"),
bucketName: "b",
storage: asStorage(new FakeGcs()),
});
expect(a.siteId).not.toBe(b.siteId);
});
it("short-circuits the upload when the object already exists", async () => {
const gcs = new FakeGcs();
const dir = mkProject("<html>cache</html>");
const first = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) });
expect(first.uploaded).toBe(true);
const second = await deploySite({ projectDir: dir, bucketName: "b", storage: asStorage(gcs) });
expect(second.uploaded).toBe(false);
expect(second.siteId).toBe(first.siteId);
// Only one upload op total.
expect(gcs.ops.filter((o) => o.kind === "upload").length).toBe(1);
});
it("honours an explicit siteId override", async () => {
const gcs = new FakeGcs();
const handle = await deploySite({
projectDir: mkProject("<html></html>"),
bucketName: "b",
siteId: "my-git-sha",
storage: asStorage(gcs),
});
expect(handle.siteId).toBe("my-git-sha");
expect(handle.projectGcsUri).toBe("gs://b/sites/my-git-sha/project.tar.gz");
});
});
@@ -0,0 +1,130 @@
/**
* `deploySite` — upload a project directory to GCS once per content hash
* and return a reusable handle.
*
* `renderToCloudRun` calls this implicitly when no `siteHandle` is passed,
* but exposing it as a standalone verb lets adopters bundle a project ahead
* of time and reuse the handle across many renders without re-tarring the
* project tree on every call.
*
* The handle is **content-addressed**: `siteId` is derived from a SHA-256
* over the project files. Two `deploySite` calls on an unchanged tree
* produce the same `siteId` and short-circuit the upload after a single
* existence check.
*/
import { mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Storage } from "@google-cloud/storage";
import { hashProjectDir } from "@hyperframes/producer/distributed";
import { formatGcsUri, tarDirectory, uploadFileToGcs } from "../gcsTransport.js";
/** Options for {@link deploySite}. */
export interface DeploySiteOptions {
/** Local project directory containing `index.html` (and any composition assets). */
projectDir: string;
/** GCS bucket the Terraform module provisioned. */
bucketName: string;
/**
* Override the content-addressed site id. Useful when the caller has a
* stable external identifier they want to use (e.g. a git SHA); if unset,
* the hash of the project tree picks it.
*/
siteId?: string;
/** Injection seam for tests. Production callers leave unset. */
storage?: Storage;
}
/** Stable handle returned by {@link deploySite}. Pass back to {@link renderToCloudRun}. */
export interface SiteHandle {
/** Content-addressed (or caller-supplied) identifier; stable across re-uploads of the same tree. */
siteId: string;
/** Bucket the site landed in. Surfaced separately so callers don't have to re-parse `projectGcsUri`. */
bucketName: string;
/** Full `gs://bucket/sites/<siteId>/project.tar.gz` URI; pass through to `renderToCloudRun`. */
projectGcsUri: string;
/** Tarball size in bytes; useful for "did we actually skip the upload?" assertions. */
bytes: number;
/** ISO timestamp of the most recent upload OR the existing object the short-circuit found. */
uploadedAt: string;
/** `false` if the object already existed and we skipped the upload. */
uploaded: boolean;
}
/**
* Upload `projectDir` to `gs://bucketName/sites/<siteId>/project.tar.gz`.
*
* Short-circuits when an object with the same key already exists in the
* bucket — `siteId` derives from the project's content hash, so the same
* bytes produce the same key, and re-uploading would be redundant.
*/
// fallow-ignore-next-line complexity
export async function deploySite(opts: DeploySiteOptions): Promise<SiteHandle> {
if (!statSync(opts.projectDir).isDirectory()) {
throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
}
const siteId = opts.siteId ?? hashProjectDir(opts.projectDir);
const key = `sites/${siteId}/project.tar.gz`;
const projectGcsUri = formatGcsUri({ bucket: opts.bucketName, key });
const storage = opts.storage ?? new Storage();
const file = storage.bucket(opts.bucketName).file(key);
// Existence short-circuit. Adopters re-rendering the same project on a
// tight inner loop (CI smoke, demo flows) save the tar+gzip+upload pass
// on every iteration.
const existing = await headObject(file);
if (existing) {
return {
siteId,
bucketName: opts.bucketName,
projectGcsUri,
bytes: existing.bytes,
uploadedAt: existing.lastModified,
uploaded: false,
};
}
const workdir = mkdtempSync(join(tmpdir(), "hf-deploy-site-"));
try {
const tarball = join(workdir, "project.tar.gz");
await tarDirectory(opts.projectDir, tarball);
const size = statSync(tarball).size;
await uploadFileToGcs(storage, tarball, projectGcsUri, "application/gzip");
return {
siteId,
bucketName: opts.bucketName,
projectGcsUri,
bytes: size,
uploadedAt: new Date().toISOString(),
uploaded: true,
};
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
/**
* Narrow surface of the `@google-cloud/storage` `File` this module uses —
* lets the test double implement just `exists()` + `getMetadata()` without
* pulling the full client type.
*/
interface FileLike {
exists(): Promise<[boolean, ...unknown[]]>;
getMetadata(): Promise<[{ size?: string | number; updated?: string }, ...unknown[]]>;
}
// fallow-ignore-next-line complexity
async function headObject(file: FileLike): Promise<{ bytes: number; lastModified: string } | null> {
const [exists] = await file.exists();
if (!exists) return null;
const [meta] = await file.getMetadata();
const sizeRaw = meta.size;
const bytes =
typeof sizeRaw === "string" ? Number(sizeRaw) : typeof sizeRaw === "number" ? sizeRaw : 0;
return {
bytes: Number.isFinite(bytes) ? bytes : 0,
lastModified: meta.updated ?? new Date().toISOString(),
};
}
@@ -0,0 +1,108 @@
/**
* `getRenderProgress` unit tests — state mapping + parsing the accumulated
* workflow result into frame totals, output file, and cost.
*/
import { describe, expect, it } from "bun:test";
import {
type ExecutionRecord,
type ExecutionsGetClientLike,
getRenderProgress,
} from "./getRenderProgress.js";
function fakeExecutions(record: ExecutionRecord): ExecutionsGetClientLike {
return {
async getExecution(_req: { name: string }) {
return [record] as [ExecutionRecord];
},
};
}
const accumulated = JSON.stringify({
Plan: { TotalFrames: 90, DurationMs: 4000 },
Chunks: [
{ FramesEncoded: 30, DurationMs: 8000 },
{ FramesEncoded: 30, DurationMs: 8000 },
{ FramesEncoded: 30, DurationMs: 8000 },
],
Assemble: { OutputGcsUri: "gs://b/renders/r1/output.mp4", FileSize: 123456, DurationMs: 3000 },
});
describe("getRenderProgress", () => {
it("reports running with no frame data while ACTIVE", async () => {
const p = await getRenderProgress({
executionName: "x",
executions: fakeExecutions({ state: "ACTIVE", startTime: { seconds: 1700000000 } }),
});
expect(p.status).toBe("running");
expect(p.overallProgress).toBe(0);
expect(p.totalFrames).toBeNull();
expect(p.fatalErrorEncountered).toBe(false);
});
it("reports succeeded with parsed frames + cost", async () => {
const p = await getRenderProgress({
executionName: "x",
vcpu: 4,
memoryGib: 16,
executions: fakeExecutions({
state: "SUCCEEDED",
result: accumulated,
startTime: { seconds: 1700000000 },
endTime: { seconds: 1700000031 },
}),
});
expect(p.status).toBe("succeeded");
expect(p.overallProgress).toBe(1);
expect(p.totalFrames).toBe(90);
expect(p.framesRendered).toBe(90);
expect(p.invocationsObserved).toBe(5); // plan + 3 chunks + assemble
expect(p.outputFile).toEqual({ gcsUri: "gs://b/renders/r1/output.mp4", bytes: 123456 });
expect(p.costs.accruedSoFarUsd).toBeGreaterThan(0);
expect(p.costs.breakdown.estimated).toBe(false);
});
it("maps FAILED to a fatal error and surfaces the error payload", async () => {
const p = await getRenderProgress({
executionName: "x",
executions: fakeExecutions({
state: "FAILED",
error: { payload: "boom", context: "renderChunk" },
}),
});
expect(p.status).toBe("failed");
expect(p.fatalErrorEncountered).toBe(true);
expect(p.errors[0]?.cause).toBe("boom");
expect(p.errors[0]?.state).toBe("renderChunk");
});
it("extracts the handler error name from a wrapped http failure payload", async () => {
// Workflows wraps an http step failure as { code, message, body }, where
// body is the handler's JSON { error, message }.
const payload = JSON.stringify({
code: 400,
message: "HTTP server responded with error code 400",
body: JSON.stringify({ error: "PLAN_HASH_MISMATCH", message: "mismatch" }),
});
const p = await getRenderProgress({
executionName: "x",
executions: fakeExecutions({ state: "FAILED", error: { payload, context: "renderChunk" } }),
});
expect(p.errors[0]?.error).toBe("PLAN_HASH_MISMATCH");
});
it("maps CANCELLED", async () => {
const p = await getRenderProgress({
executionName: "x",
executions: fakeExecutions({ state: "CANCELLED" }),
});
expect(p.status).toBe("cancelled");
expect(p.fatalErrorEncountered).toBe(true);
});
it("requires an executionName", async () => {
await expect(
getRenderProgress({ executionName: "", executions: fakeExecutions({}) }),
).rejects.toThrow(/executionName is required/);
});
});
@@ -0,0 +1,268 @@
/**
* `getRenderProgress` — read-only progress + cost snapshot for a single
* render started by {@link renderToCloudRun}.
*
* Pulls one `GetExecution` per call. Cloud Workflows does not surface
* per-step payloads through the basic Executions API the way Step Functions
* exposes its history, so this reader takes a different tack than the AWS
* adapter: the workflow definition **accumulates** each step's result body
* (Plan + every RenderChunk + Assemble) and returns them as one structured
* object. On success we parse that object for frame totals, the output
* file, and per-step `DurationMs` (which the handler stamps into every
* result), then compute cost against the service's configured vCPU/memory.
*
* Progress is therefore coarse while the execution is ACTIVE (we report
* `running` with `overallProgress = 0`) and exact once it SUCCEEDS
* (`overallProgress = 1`, real frame + cost numbers). Mid-flight per-chunk
* progress would require the Workflows step-entries API; that's a tracked
* follow-up, not part of the first version.
*/
import {
type BilledCloudRunInvocation,
computeRenderCost,
type RenderCost,
} from "./costAccounting.js";
/** Normalised render status. Maps from Cloud Workflows execution states. */
export type RenderStatus = "running" | "succeeded" | "failed" | "cancelled" | "unknown";
/** One error surfaced by the execution. */
export interface RenderError {
/** Step the failure surfaced in, when recoverable from the error context; else `<execution>`. */
state: string;
/** Error class / type. */
error: string;
/** Cause string (often a stringified JSON payload from the handler). */
cause: string;
}
/** Snapshot of a single render's progress + cost + errors at one point in time. */
export interface RenderProgress {
status: RenderStatus;
/** `[0, 1]`; coarse while running, exact on success. */
overallProgress: number;
framesRendered: number;
/** `null` until the execution succeeds and the accumulated plan result is read. */
totalFrames: number | null;
/** Cloud Run invocations the workflow scheduled (Plan + chunks + Assemble), when known. */
invocationsObserved: number;
costs: RenderCost;
/** Final output object if Assemble succeeded; `null` otherwise. */
outputFile: { gcsUri: string; bytes: number | null } | null;
errors: RenderError[];
/** `true` once the execution has terminated in a non-success state. */
fatalErrorEncountered: boolean;
startedAt: string;
endedAt: string | null;
}
/** Protobuf Timestamp shape the gapic client returns for start/end times. */
interface ProtoTimestamp {
seconds?: number | string | null;
nanos?: number | null;
}
/** Subset of a Cloud Workflows Execution this reader consumes. */
export interface ExecutionRecord {
name?: string | null;
state?: string | null;
result?: string | null;
error?: { payload?: string | null; context?: string | null } | null;
startTime?: ProtoTimestamp | string | null;
endTime?: ProtoTimestamp | string | null;
}
/** Minimal surface of `@google-cloud/workflows`' `ExecutionsClient` for reads. */
export interface ExecutionsGetClientLike {
getExecution(req: { name: string }): Promise<[ExecutionRecord, ...unknown[]]>;
}
/** Options for {@link getRenderProgress}. */
export interface GetRenderProgressOptions {
/** Server-assigned execution resource name from a {@link renderToCloudRun} call. */
executionName: string;
/** vCPU the Cloud Run service is configured with (for cost). Default 4. */
vcpu?: number;
/** Memory in GiB the Cloud Run service is configured with (for cost). Default 16. */
memoryGib?: number;
/** Test injection seam — production callers leave unset. */
executions?: ExecutionsGetClientLike;
}
const DEFAULT_VCPU = 4;
const DEFAULT_MEMORY_GIB = 16;
/** Result body the handler returns for each action; the workflow accumulates these. */
interface AccumulatedResult {
Plan?: { TotalFrames?: number; DurationMs?: number } | null;
Chunks?: Array<{ FramesEncoded?: number; DurationMs?: number } | null> | null;
Assemble?: {
OutputGcsUri?: string;
FileSize?: number;
FramesEncoded?: number;
DurationMs?: number;
} | null;
}
/** Pull a current progress snapshot for one render. */
// fallow-ignore-next-line complexity
export async function getRenderProgress(opts: GetRenderProgressOptions): Promise<RenderProgress> {
if (!opts.executionName) {
throw new Error("[getRenderProgress] executionName is required");
}
const executions = opts.executions ?? (await defaultExecutionsClient());
const vcpu = opts.vcpu ?? DEFAULT_VCPU;
const memoryGib = opts.memoryGib ?? DEFAULT_MEMORY_GIB;
const [execution] = await executions.getExecution({ name: opts.executionName });
const status = mapState(execution.state);
const startedAt = toIso(execution.startTime) ?? new Date(0).toISOString();
const endedAt = toIso(execution.endTime);
const errors: RenderError[] = [];
if (execution.error) {
errors.push({
state: execution.error.context ?? "<execution>",
error: extractErrorName(execution.error.payload) ?? "ExecutionError",
cause: execution.error.payload ?? "",
});
}
// Default snapshot: running / unknown — no frame or cost data until the
// accumulated result is available on success.
if (status !== "succeeded") {
return {
status,
overallProgress: 0,
framesRendered: 0,
totalFrames: null,
invocationsObserved: 0,
costs: computeRenderCost([], 0),
outputFile: null,
errors,
fatalErrorEncountered: status === "failed" || status === "cancelled",
startedAt,
endedAt,
};
}
const acc = parseAccumulated(execution.result);
const chunks = acc.Chunks?.filter((c): c is NonNullable<typeof c> => c != null) ?? [];
const framesRendered = chunks.reduce((sum, c) => sum + (c.FramesEncoded ?? 0), 0);
const totalFrames = typeof acc.Plan?.TotalFrames === "number" ? acc.Plan.TotalFrames : null;
const invocations: BilledCloudRunInvocation[] = [];
const pushInv = (durationMs: number | undefined): void => {
invocations.push({
durationMs: typeof durationMs === "number" ? durationMs : 0,
vcpu,
memoryGib,
estimated: typeof durationMs !== "number",
});
};
if (acc.Plan) pushInv(acc.Plan.DurationMs);
for (const c of chunks) pushInv(c.DurationMs);
if (acc.Assemble) pushInv(acc.Assemble.DurationMs);
// Workflow step count: Plan + N chunks + Assemble + a small constant of
// control steps (BuildChunkList, AssertChunkCount, the map scaffold).
const workflowSteps = invocations.length + 4;
const costs = computeRenderCost(invocations, workflowSteps);
const outputGcsUri = acc.Assemble?.OutputGcsUri;
const outputFile = outputGcsUri
? {
gcsUri: outputGcsUri,
bytes: typeof acc.Assemble?.FileSize === "number" ? acc.Assemble.FileSize : null,
}
: null;
return {
status,
overallProgress: 1,
framesRendered,
totalFrames,
invocationsObserved: invocations.length,
costs,
outputFile,
errors,
fatalErrorEncountered: false,
startedAt,
endedAt,
};
}
// fallow-ignore-next-line complexity
function mapState(state: string | null | undefined): RenderStatus {
switch (state) {
case "ACTIVE":
case "QUEUED":
return "running";
case "SUCCEEDED":
return "succeeded";
case "FAILED":
case "UNAVAILABLE":
return "failed";
case "CANCELLED":
return "cancelled";
default:
return "unknown";
}
}
// fallow-ignore-next-line complexity
function parseAccumulated(result: string | null | undefined): AccumulatedResult {
if (!result) return {};
try {
const parsed = JSON.parse(result) as unknown;
if (parsed && typeof parsed === "object") return parsed as AccumulatedResult;
} catch {
// Non-JSON result — treat as empty so cost/frames degrade to zero
// rather than throwing on a snapshot read.
}
return {};
}
/**
* Best-effort pull of the handler's error name out of a Workflows failure
* payload. On an http step failure, Workflows wraps the response as
* `{ code, message, body, ... }` where `body` is the handler's JSON
* `{ error, message }`. We dig out `error` (the typed name like
* `PLAN_HASH_MISMATCH`) so triage sees the real cause, not a generic label.
* Returns undefined for any shape we don't recognise — never throws.
*/
// fallow-ignore-next-line complexity
function extractErrorName(payload: string | null | undefined): string | undefined {
if (!payload) return undefined;
try {
const outer = JSON.parse(payload) as { error?: unknown; body?: unknown };
if (typeof outer.error === "string") return outer.error;
if (typeof outer.body === "string") {
const inner = JSON.parse(outer.body) as { error?: unknown };
if (typeof inner.error === "string") return inner.error;
} else if (outer.body && typeof outer.body === "object") {
const inner = outer.body as { error?: unknown };
if (typeof inner.error === "string") return inner.error;
}
} catch {
// Non-JSON / unexpected shape — fall through to the generic label.
}
return undefined;
}
// fallow-ignore-next-line complexity
function toIso(ts: ProtoTimestamp | string | null | undefined): string | null {
if (ts == null) return null;
if (typeof ts === "string") return ts;
const seconds = ts.seconds == null ? null : Number(ts.seconds);
if (seconds == null || !Number.isFinite(seconds)) return null;
const ms = seconds * 1000 + (ts.nanos ?? 0) / 1e6;
return new Date(ms).toISOString();
}
async function defaultExecutionsClient(): Promise<ExecutionsGetClientLike> {
const mod = await import("@google-cloud/workflows");
const client = new mod.ExecutionsClient();
return client as unknown as ExecutionsGetClientLike;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* SDK subpath export — `@hyperframes/gcp-cloud-run/sdk`.
*
* Pulled into its own subpath so consumers that only drive renders (CLI, CI
* scripts, adopter tooling) don't pay the cost of importing `./server.js`,
* which transitively pulls `puppeteer-core` into the module graph. The SDK
* files here are GCS + Workflows clients only — safe to load in any Node
* environment.
*/
export { deploySite, type DeploySiteOptions, type SiteHandle } from "./deploySite.js";
export {
type ExecutionsClientLike,
renderToCloudRun,
type RenderHandle,
type RenderToCloudRunOptions,
} from "./renderToCloudRun.js";
export {
type ExecutionRecord,
type ExecutionsGetClientLike,
getRenderProgress,
type GetRenderProgressOptions,
type RenderError,
type RenderProgress,
type RenderStatus,
} from "./getRenderProgress.js";
export {
type BilledCloudRunInvocation,
computeRenderCost,
type RenderCost,
} from "./costAccounting.js";
export {
InvalidConfigError,
MAX_WORKFLOWS_INPUT_BYTES,
validateDistributedRenderConfig,
validateVariablesPayload,
validateWorkflowsInputSize,
} from "./validateConfig.js";
export type { SerializableDistributedRenderConfig } from "../events.js";
export type { DistributedFormat } from "../formatExtension.js";
@@ -0,0 +1,127 @@
/**
* `renderToCloudRun` unit tests — argument assembly, required-field
* validation, and the CreateExecution call over a fake ExecutionsClient.
*/
import { describe, expect, it } from "bun:test";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { type ExecutionsClientLike, renderToCloudRun } from "./renderToCloudRun.js";
import type { SiteHandle } from "./deploySite.js";
const config = {
fps: 30,
width: 1920,
height: 1080,
format: "mp4",
} as SerializableDistributedRenderConfig;
const site: SiteHandle = {
siteId: "abc",
bucketName: "b",
projectGcsUri: "gs://b/sites/abc/project.tar.gz",
bytes: 100,
uploadedAt: "2026-06-06T00:00:00Z",
uploaded: true,
};
class FakeExecutions implements ExecutionsClientLike {
lastArgument: string | null = null;
lastParent: string | null = null;
workflowPath(project: string, location: string, workflow: string): string {
return `projects/${project}/locations/${location}/workflows/${workflow}`;
}
async createExecution(req: {
parent: string;
execution: { argument: string };
}): Promise<[{ name?: string | null; state?: string | null }]> {
this.lastParent = req.parent;
this.lastArgument = req.execution.argument;
return [{ name: `${req.parent}/executions/exec-123`, state: "ACTIVE" }];
}
}
function opts(executions: ExecutionsClientLike) {
return {
siteHandle: site,
config,
bucketName: "b",
projectId: "proj",
location: "us-central1",
workflowId: "hyperframes-render",
serviceUrl: "https://render-abc.run.app",
renderId: "hf-render-fixed",
executions,
};
}
describe("renderToCloudRun", () => {
it("starts an execution and returns a handle", async () => {
const fake = new FakeExecutions();
const handle = await renderToCloudRun(opts(fake));
expect(handle.renderId).toBe("hf-render-fixed");
expect(handle.executionName).toBe(
"projects/proj/locations/us-central1/workflows/hyperframes-render/executions/exec-123",
);
expect(handle.outputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.mp4");
expect(handle.projectGcsUri).toBe("gs://b/sites/abc/project.tar.gz");
});
it("builds the workflow argument the YAML expects", async () => {
const fake = new FakeExecutions();
await renderToCloudRun(opts(fake));
const arg = JSON.parse(fake.lastArgument ?? "{}");
expect(arg.RenderId).toBe("hf-render-fixed");
expect(arg.ProjectGcsUri).toBe("gs://b/sites/abc/project.tar.gz");
expect(arg.PlanOutputGcsPrefix).toBe("gs://b/renders/hf-render-fixed/");
expect(arg.OutputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.mp4");
expect(arg.ServiceUrl).toBe("https://render-abc.run.app");
expect(arg.Config.format).toBe("mp4");
expect(fake.lastParent).toBe(
"projects/proj/locations/us-central1/workflows/hyperframes-render",
);
});
it("derives the output extension from the format", async () => {
const fake = new FakeExecutions();
const handle = await renderToCloudRun({
...opts(fake),
config: { ...config, format: "webm" } as SerializableDistributedRenderConfig,
});
expect(handle.outputGcsUri).toBe("gs://b/renders/hf-render-fixed/output.webm");
});
it("requires serviceUrl", async () => {
const fake = new FakeExecutions();
await expect(renderToCloudRun({ ...opts(fake), serviceUrl: "" })).rejects.toThrow(
/serviceUrl is required/,
);
});
it("requires a siteHandle or projectDir", async () => {
const fake = new FakeExecutions();
const { siteHandle, ...rest } = opts(fake);
void siteHandle;
await expect(renderToCloudRun(rest)).rejects.toThrow(/siteHandle or projectDir/);
});
it("validates the config before any GCP call", async () => {
const fake = new FakeExecutions();
await expect(
renderToCloudRun({ ...opts(fake), config: { ...config, fps: 25 } as never }),
).rejects.toThrow(/config\.fps/);
expect(fake.lastArgument).toBeNull();
});
it("rejects a renderId that could escape the GCS key prefix", async () => {
const fake = new FakeExecutions();
await expect(renderToCloudRun({ ...opts(fake), renderId: "../escape" })).rejects.toThrow(
/renderId must match/,
);
await expect(renderToCloudRun({ ...opts(fake), renderId: "has/slash" })).rejects.toThrow(
/renderId must match/,
);
expect(fake.lastArgument).toBeNull();
});
});
@@ -0,0 +1,188 @@
/**
* `renderToCloudRun` — start a distributed render against an already-deployed
* Cloud Run service + Cloud Workflows definition and return a handle the
* caller can poll with {@link getRenderProgress}.
*
* The function does *not* wait for the render to finish. Cloud Workflows
* executions can run for hours; blocking the caller's process on the
* execution is the wrong default. The returned `RenderHandle` carries
* everything the progress / cost / download paths need.
*
* Wire order:
* 1. Validate config (typed throw before any GCP call).
* 2. `deploySite` if no `siteHandle` was provided.
* 3. `CreateExecution` against the workflow with the argument shape the
* `packages/gcp-cloud-run/terraform/workflow.yaml` definition expects.
* 4. Return handle. The GCS `outputKey` is deterministic from the
* client-generated `renderId` so the caller can predict the final
* object URL before the (server-assigned) execution id exists.
*
* Unlike Step Functions, Cloud Workflows assigns the execution id
* server-side, so we cannot use it as the GCS prefix. We mint a `renderId`
* (uuid) client-side, use it for every GCS path, and pass it into the
* workflow argument; the server-assigned execution resource name is tracked
* separately for polling.
*/
import { randomUUID } from "node:crypto";
import type { Storage } from "@google-cloud/storage";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatGcsUri } from "../gcsTransport.js";
import { deploySite, type SiteHandle } from "./deploySite.js";
import { validateDistributedRenderConfig, validateWorkflowsInputSize } from "./validateConfig.js";
/**
* Minimal surface of `@google-cloud/workflows`' `ExecutionsClient` that
* this module needs. The real client satisfies this; tests inject a double.
*/
export interface ExecutionsClientLike {
workflowPath(project: string, location: string, workflow: string): string;
createExecution(req: {
parent: string;
execution: { argument: string };
}): Promise<[{ name?: string | null; state?: string | null }, ...unknown[]]>;
}
/** Options for {@link renderToCloudRun}. */
export interface RenderToCloudRunOptions {
/** Local project directory. Required when `siteHandle` is not supplied. */
projectDir?: string;
/** Re-use an existing `deploySite` upload (skips tar+GCS upload). */
siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/** GCS bucket from the Terraform output (`render_bucket_name`). */
bucketName: string;
/** GCP project id hosting the workflow. */
projectId: string;
/** Workflow location, e.g. `us-central1`. */
location: string;
/** Workflow id from the Terraform output (`workflow_name`). */
workflowId: string;
/**
* HTTPS URL of the deployed Cloud Run render service (Terraform output
* `service_url`). The workflow POSTs every step (plan / renderChunk /
* assemble) to this URL; passed as an execution argument so the workflow
* definition stays free of hard-coded URLs.
*/
serviceUrl: string;
/**
* Final output GCS key. Defaults to `renders/<renderId>/output.<ext>`
* where `<ext>` is derived from `config.format`.
*/
outputKey?: string;
/**
* Client-generated render id. Defaults to `hf-render-<uuid>`. Used as the
* GCS key prefix and echoed into the workflow argument; not the same as
* the server-assigned execution id.
*/
renderId?: string;
/** Test injection seam — production callers leave unset. */
executions?: ExecutionsClientLike;
/** Test injection seam — propagated to `deploySite` when applicable. */
storage?: Storage;
}
/** Stable identifier + every URL/name the caller needs to follow the render. */
export interface RenderHandle {
/** Client-generated render id; the GCS prefix everything lands under. */
renderId: string;
/** Server-assigned execution resource name; pass to {@link getRenderProgress}. */
executionName: string;
bucketName: string;
workflowId: string;
outputGcsUri: string;
projectGcsUri: string;
startedAt: string;
}
// fallow-ignore-next-line complexity
export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise<RenderHandle> {
validateDistributedRenderConfig(opts.config);
if (!opts.bucketName) throw new Error("[renderToCloudRun] bucketName is required");
if (!opts.projectId) throw new Error("[renderToCloudRun] projectId is required");
if (!opts.location) throw new Error("[renderToCloudRun] location is required");
if (!opts.workflowId) throw new Error("[renderToCloudRun] workflowId is required");
if (!opts.serviceUrl) throw new Error("[renderToCloudRun] serviceUrl is required");
if (!opts.siteHandle && !opts.projectDir) {
throw new Error("[renderToCloudRun] either siteHandle or projectDir must be supplied");
}
const renderId = opts.renderId ?? `hf-render-${randomUUID()}`;
// `renderId` is interpolated directly into GCS object keys
// (`renders/<renderId>/…`). Reject anything that could escape that prefix
// or build a malformed key — `..`, slashes, or other path metacharacters —
// so a caller-supplied id can't collide with or overwrite another render's
// artifacts elsewhere in the bucket.
if (!/^[A-Za-z0-9._-]+$/.test(renderId) || renderId.includes("..")) {
throw new Error(
`[renderToCloudRun] renderId must match [A-Za-z0-9._-]+ and not contain "..": ${JSON.stringify(renderId)}`,
);
}
const ext = formatExtension(opts.config.format);
const outputKey = opts.outputKey ?? `renders/${renderId}/output${ext}`;
const planOutputGcsPrefix = formatGcsUri({
bucket: opts.bucketName,
key: `renders/${renderId}/`,
});
const outputGcsUri = formatGcsUri({ bucket: opts.bucketName, key: outputKey });
const site =
opts.siteHandle ??
(await deploySite({
projectDir: opts.projectDir as string,
bucketName: opts.bucketName,
storage: opts.storage,
}));
const argument = {
RenderId: renderId,
ProjectGcsUri: site.projectGcsUri,
PlanOutputGcsPrefix: planOutputGcsPrefix,
OutputGcsUri: outputGcsUri,
ServiceUrl: opts.serviceUrl,
Config: opts.config,
};
// Reject oversize input client-side. Cloud Workflows caps the execution
// argument at 512 KiB; without this check, input bloat (typically from
// `config.variables` containing inlined media) surfaces as an opaque
// server-side error after the execution starts, far from the caller's
// stack frame.
validateWorkflowsInputSize(argument);
const executions = opts.executions ?? (await defaultExecutionsClient());
const parent = executions.workflowPath(opts.projectId, opts.location, opts.workflowId);
const startedAt = new Date().toISOString();
const [execution] = await executions.createExecution({
parent,
execution: { argument: JSON.stringify(argument) },
});
if (!execution.name) {
throw new Error("[renderToCloudRun] CreateExecution returned no execution name");
}
return {
renderId,
executionName: execution.name,
bucketName: opts.bucketName,
workflowId: opts.workflowId,
outputGcsUri,
projectGcsUri: site.projectGcsUri,
startedAt,
};
}
/**
* Lazily import the real `@google-cloud/workflows` ExecutionsClient. Dynamic
* so SDK consumers that only call `validateDistributedRenderConfig` (or
* inject their own client) don't pay the import cost.
*/
async function defaultExecutionsClient(): Promise<ExecutionsClientLike> {
const mod = await import("@google-cloud/workflows");
const client = new mod.ExecutionsClient();
return client as unknown as ExecutionsClientLike;
}
@@ -0,0 +1,119 @@
/**
* `validateDistributedRenderConfig` + `validateWorkflowsInputSize` unit
* tests. Pins the shape rejections the SDK surfaces synchronously before a
* Cloud Workflows execution starts.
*/
import { describe, expect, it } from "bun:test";
import type { SerializableDistributedRenderConfig } from "../events.js";
import {
InvalidConfigError,
MAX_WORKFLOWS_INPUT_BYTES,
validateDistributedRenderConfig,
validateVariablesPayload,
validateWorkflowsInputSize,
} from "./validateConfig.js";
function base(): SerializableDistributedRenderConfig {
return {
fps: 30,
width: 1920,
height: 1080,
format: "mp4",
} as SerializableDistributedRenderConfig;
}
describe("validateDistributedRenderConfig", () => {
it("accepts a minimal valid config", () => {
expect(validateDistributedRenderConfig(base())).toBeDefined();
});
it("rejects a bad fps", () => {
expect(() => validateDistributedRenderConfig({ ...base(), fps: 25 } as never)).toThrow(
/config\.fps/,
);
});
it("rejects odd dimensions (yuv420p)", () => {
expect(() => validateDistributedRenderConfig({ ...base(), width: 1921 })).toThrow(/even/);
});
it("rejects an out-of-range dimension", () => {
expect(() => validateDistributedRenderConfig({ ...base(), height: 8 })).toThrow(/\[16, 7680\]/);
});
it("rejects an unknown format", () => {
expect(() => validateDistributedRenderConfig({ ...base(), format: "gif" as never })).toThrow(
/config\.format/,
);
});
it("rejects codec with a non-mp4 format", () => {
expect(() =>
validateDistributedRenderConfig({ ...base(), format: "webm", codec: "h264" } as never),
).toThrow(/only valid with format="mp4"/);
});
it("rejects crf + bitrate together", () => {
expect(() =>
validateDistributedRenderConfig({ ...base(), crf: 20, bitrate: "10M" } as never),
).toThrow(/mutually exclusive/);
});
it("rejects force-hdr", () => {
expect(() =>
validateDistributedRenderConfig({ ...base(), hdrMode: "force-hdr" as never }),
).toThrow(/force-sdr/);
});
it("rejects an over-cap chunkSize", () => {
expect(() => validateDistributedRenderConfig({ ...base(), chunkSize: 5000 } as never)).toThrow(
/<= 3600/,
);
});
it("throws InvalidConfigError with a field pointer", () => {
try {
validateDistributedRenderConfig({ ...base(), fps: 1 } as never);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.fps");
}
});
});
describe("validateVariablesPayload", () => {
it("accepts a plain JSON object", () => {
expect(() =>
validateVariablesPayload({ title: "Hi", count: 3, nested: { ok: true } }),
).not.toThrow();
});
it("rejects undefined leaves", () => {
expect(() => validateVariablesPayload({ a: undefined })).toThrow(/undefined leaves/);
});
it("rejects a top-level array", () => {
expect(() => validateVariablesPayload([1, 2])).toThrow(/plain JSON object/);
});
it("rejects NaN", () => {
expect(() => validateVariablesPayload({ x: NaN })).toThrow(/non-finite/);
});
it("rejects a Date (non-plain object)", () => {
expect(() => validateVariablesPayload({ when: new Date(0) })).toThrow(/non-plain objects/);
});
});
describe("validateWorkflowsInputSize", () => {
it("accepts a small payload", () => {
expect(() => validateWorkflowsInputSize({ a: "b" })).not.toThrow();
});
it("rejects a payload over the 512 KiB cap", () => {
const big = { blob: "x".repeat(MAX_WORKFLOWS_INPUT_BYTES + 1) };
expect(() => validateWorkflowsInputSize(big)).toThrow(/512 KiB/);
});
});
@@ -0,0 +1,77 @@
/**
* Client-side validation for the Cloud Run adapter.
*
* The cloud-agnostic config-shape validation (`validateDistributedRenderConfig`,
* `validateVariablesPayload`, `InvalidConfigError`) lives in
* `@hyperframes/producer/distributed` and is shared with the other adapters.
* This module re-exports those and adds the one piece that is specific to
* Cloud Workflows: the 512 KiB execution-argument size cap.
*/
import { InvalidConfigError } from "@hyperframes/producer/distributed";
export {
InvalidConfigError,
validateDistributedRenderConfig,
validateVariablesPayload,
} from "@hyperframes/producer/distributed";
/**
* Hard cap on Cloud Workflows execution arguments — 512 KiB per the Workflows
* quotas page (maximum size of arguments passed when an execution starts).
* The cap is on the entire serialized argument, not just the variables,
* because users hit it at the wire boundary regardless of which field caused
* the bloat.
*
* Specific to Cloud Workflows. Other runtimes (Lambda + Step Functions,
* Temporal) have different caps; don't reuse this constant for those without
* confirming the limit.
*/
export const MAX_WORKFLOWS_INPUT_BYTES = 512 * 1024;
/** Pointer to the docs section that explains the URL-your-assets convention. */
const LARGE_VARIABLES_DOCS_URL =
"https://hyperframes.heygen.com/deploy/templates-on-lambda#working-with-large-variables";
/**
* Validate that the serialized Cloud Workflows execution argument fits inside
* the 512 KiB cap. Measured in UTF-8 bytes (the format the API uses on the
* wire) — JS strings count UTF-16 code units, which under-reports for any
* multi-byte character.
*
* Throws {@link InvalidConfigError} with a clear message naming the actual
* byte count, the cap, and a pointer to the "working with large variables"
* docs section, so users hit the limit at the SDK boundary with actionable
* guidance instead of as an opaque argument-too-large error after the
* execution starts.
*/
// fallow-ignore-next-line complexity
export function validateWorkflowsInputSize(input: unknown): void {
let serialized: string | undefined;
try {
serialized = JSON.stringify(input);
} catch (err) {
throw new InvalidConfigError(
"config",
`Cloud Workflows execution argument is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (serialized === undefined) {
throw new InvalidConfigError(
"config",
"Cloud Workflows execution argument is not JSON-serializable (JSON.stringify returned undefined). " +
"Check that all fields, including config.variables, are plain JSON values.",
);
}
const byteLength = Buffer.byteLength(serialized, "utf8");
if (byteLength > MAX_WORKFLOWS_INPUT_BYTES) {
throw new InvalidConfigError(
"config",
`Cloud Workflows execution argument is ${byteLength} bytes, which exceeds the ` +
`${MAX_WORKFLOWS_INPUT_BYTES}-byte (512 KiB) limit. Variables are for typed data ` +
`(strings, numbers, structured records); media assets (images, audio, video) should ` +
`be passed as URL references the composition resolves at render time, not inlined as ` +
`base64. See ${LARGE_VARIABLES_DOCS_URL} for the URL-your-assets convention.`,
);
}
}