From c3a0b4e61aaa54a8396b3bbdb3e7543e50b7993f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miguel=20=C3=81ngel?= <miguel.sierra@heygen.com>
Date: Tue, 5 May 2026 14:29:10 -0700
Subject: [PATCH 1/2] fix(cli): use size-adaptive timeouts for publish uploads

The flat 30s timeout caused large project uploads to abort before the
S3 PUT could complete. Compute timeout from archive size at ~500 KB/s
with a 120s floor, so a 78 MB project gets ~164s and a 500 MB project
gets ~17 min. Metadata requests keep the original 30s timeout.
---
 packages/cli/src/utils/publishProject.ts | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts
index 3b4d62855..cc1b44aac 100644
--- a/packages/cli/src/utils/publishProject.ts
+++ b/packages/cli/src/utils/publishProject.ts
@@ -5,7 +5,9 @@ import AdmZip from "adm-zip";
 const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]);
 const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]);
 const PUBLISH_CONTENT_TYPE = "application/zip";
-const PUBLISH_REQUEST_TIMEOUT_MS = 30_000;
+const PUBLISH_METADATA_TIMEOUT_MS = 30_000;
+const PUBLISH_UPLOAD_MIN_TIMEOUT_MS = 120_000;
+const PUBLISH_UPLOAD_BYTES_PER_SECOND = 500_000;
 
 export interface PublishArchiveResult {
   buffer: Buffer;
@@ -142,6 +144,13 @@ async function readErrorMessage(response: Response, fallback: string): Promise<s
   return text.trim() ? `${fallback}: ${text.trim().slice(0, 180)}` : fallback;
 }
 
+function uploadTimeoutMs(byteLength: number): number {
+  return Math.max(
+    PUBLISH_UPLOAD_MIN_TIMEOUT_MS,
+    Math.ceil((byteLength / PUBLISH_UPLOAD_BYTES_PER_SECOND) * 1000),
+  );
+}
+
 function shouldIgnoreSegment(segment: string): boolean {
   return segment.startsWith(".") || IGNORED_DIRS.has(segment) || IGNORED_FILES.has(segment);
 }
@@ -214,7 +223,7 @@ async function publishProjectArchiveDirect(
     method: "POST",
     body,
     headers,
-    signal: AbortSignal.timeout(PUBLISH_REQUEST_TIMEOUT_MS),
+    signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
   });
 
   const payload = await readJson(response);
@@ -243,7 +252,7 @@ async function publishProjectArchiveStaged(
       "content-type": "application/json",
       heygen_route: "canary",
     },
-    signal: AbortSignal.timeout(PUBLISH_REQUEST_TIMEOUT_MS),
+    signal: AbortSignal.timeout(PUBLISH_METADATA_TIMEOUT_MS),
   });
 
   if (uploadResponse.status === 404 || uploadResponse.status === 405) {
@@ -260,7 +269,7 @@ async function publishProjectArchiveStaged(
     method: "PUT",
     body: new Blob([archiveArrayBuffer(archive)], { type: stagedUpload.contentType }),
     headers: stagedUpload.uploadHeaders,
-    signal: AbortSignal.timeout(PUBLISH_REQUEST_TIMEOUT_MS),
+    signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
   });
   if (!s3Response.ok) {
     throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive"));
@@ -277,7 +286,7 @@ async function publishProjectArchiveStaged(
       "content-type": "application/json",
       heygen_route: "canary",
     },
-    signal: AbortSignal.timeout(PUBLISH_REQUEST_TIMEOUT_MS),
+    signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
   });
 
   const completePayload = await readJson(completeResponse);

From 55ea39e5ceffa4a24b74591fa0f7c354be823f76 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miguel=20=C3=81ngel?= <miguel.sierra@heygen.com>
Date: Tue, 5 May 2026 14:50:40 -0700
Subject: [PATCH 2/2] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94=20?=
 =?UTF-8?q?cap=20at=20presigned=20URL=20TTL,=20add=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Parse expires_in_seconds from the upload response and cap the S3 PUT
  timeout at TTL minus 30s safety margin — prevents the silent dead
  zone where the presigned URL expires before the client gives up
- Add unit tests for uploadTimeoutMs: floor at 120s, scales above 64 MB,
  always returns an integer
- Document the 500 KB/s bandwidth assumption
---
 packages/cli/src/utils/publishProject.test.ts | 17 +++++++++++++++++
 packages/cli/src/utils/publishProject.ts      | 13 +++++++++++--
 2 files changed, 28 insertions(+), 2 deletions(-)

diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts
index b5a3b8117..4764e0409 100644
--- a/packages/cli/src/utils/publishProject.test.ts
+++ b/packages/cli/src/utils/publishProject.test.ts
@@ -7,6 +7,7 @@ import {
   createPublishArchive,
   getPublishApiBaseUrl,
   publishProjectArchive,
+  uploadTimeoutMs,
 } from "./publishProject.js";
 
 function makeProjectDir(): string {
@@ -35,6 +36,22 @@ describe("createPublishArchive", () => {
   });
 });
 
+describe("uploadTimeoutMs", () => {
+  it("returns the minimum timeout for small files", () => {
+    expect(uploadTimeoutMs(0)).toBe(120_000);
+    expect(uploadTimeoutMs(50 * 1024 * 1024)).toBe(120_000);
+  });
+
+  it("scales above the floor for large files", () => {
+    expect(uploadTimeoutMs(64 * 1024 * 1024)).toBeGreaterThan(120_000);
+    expect(uploadTimeoutMs(500 * 1024 * 1024)).toBeGreaterThan(900_000);
+  });
+
+  it("returns an integer", () => {
+    expect(Number.isInteger(uploadTimeoutMs(123_456))).toBe(true);
+  });
+});
+
 describe("publishProjectArchive", () => {
   beforeEach(() => {
     vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts
index cc1b44aac..9a317b619 100644
--- a/packages/cli/src/utils/publishProject.ts
+++ b/packages/cli/src/utils/publishProject.ts
@@ -7,6 +7,8 @@ const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]);
 const PUBLISH_CONTENT_TYPE = "application/zip";
 const PUBLISH_METADATA_TIMEOUT_MS = 30_000;
 const PUBLISH_UPLOAD_MIN_TIMEOUT_MS = 120_000;
+// Conservative floor — most connections are faster, but this prevents
+// premature aborts on slow/unstable networks (hotel wifi, tethering).
 const PUBLISH_UPLOAD_BYTES_PER_SECOND = 500_000;
 
 export interface PublishArchiveResult {
@@ -27,6 +29,7 @@ interface StagedUploadResponse {
   uploadKey: string;
   contentType: string;
   uploadHeaders: Record<string, string>;
+  expiresInSeconds: number;
 }
 
 type JsonRecord = Record<string, unknown>;
@@ -75,11 +78,14 @@ function parseStagedUploadResponse(
   const uploadKey = stringField(data, "upload_key");
   const contentType = stringField(data, "content_type") || PUBLISH_CONTENT_TYPE;
   if (!uploadUrl || !uploadKey) return null;
+  const rawExpires = data["expires_in_seconds"];
+  const expiresInSeconds = typeof rawExpires === "number" && rawExpires > 0 ? rawExpires : 1800;
   return {
     uploadUrl,
     uploadKey,
     contentType,
     uploadHeaders: getUploadHeaders(data, uploadUrl, contentType, archiveByteLength),
+    expiresInSeconds,
   };
 }
 
@@ -144,7 +150,7 @@ async function readErrorMessage(response: Response, fallback: string): Promise<s
   return text.trim() ? `${fallback}: ${text.trim().slice(0, 180)}` : fallback;
 }
 
-function uploadTimeoutMs(byteLength: number): number {
+export function uploadTimeoutMs(byteLength: number): number {
   return Math.max(
     PUBLISH_UPLOAD_MIN_TIMEOUT_MS,
     Math.ceil((byteLength / PUBLISH_UPLOAD_BYTES_PER_SECOND) * 1000),
@@ -265,11 +271,14 @@ async function publishProjectArchiveStaged(
     throw new Error(await readErrorMessage(uploadResponse, "Failed to prepare project upload"));
   }
 
+  const presignedUrlTtlMs = stagedUpload.expiresInSeconds * 1000 - PUBLISH_METADATA_TIMEOUT_MS;
   const s3Response = await fetch(stagedUpload.uploadUrl, {
     method: "PUT",
     body: new Blob([archiveArrayBuffer(archive)], { type: stagedUpload.contentType }),
     headers: stagedUpload.uploadHeaders,
-    signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
+    signal: AbortSignal.timeout(
+      Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs),
+    ),
   });
   if (!s3Response.ok) {
     throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive"));
