From c3a0b4e61aaa54a8396b3bbdb3e7543e50b7993f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= 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 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; + expiresInSeconds: number; } type JsonRecord = Record; @@ -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