Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/cli/src/utils/publishProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createPublishArchive,
getPublishApiBaseUrl,
publishProjectArchive,
uploadTimeoutMs,
} from "./publishProject.js";

function makeProjectDir(): string {
Expand Down Expand Up @@ -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", "");
Expand Down
28 changes: 23 additions & 5 deletions packages/cli/src/utils/publishProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ 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;
// 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 {
buffer: Buffer;
Expand All @@ -25,6 +29,7 @@ interface StagedUploadResponse {
uploadKey: string;
contentType: string;
uploadHeaders: Record<string, string>;
expiresInSeconds: number;
}

type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -73,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,
};
}

Expand Down Expand Up @@ -142,6 +150,13 @@ async function readErrorMessage(response: Response, fallback: string): Promise<s
return text.trim() ? `${fallback}: ${text.trim().slice(0, 180)}` : fallback;
}

export 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);
}
Expand Down Expand Up @@ -214,7 +229,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);
Expand Down Expand Up @@ -243,7 +258,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) {
Expand All @@ -256,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(PUBLISH_REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(
Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs),
),
});
if (!s3Response.ok) {
throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive"));
Expand All @@ -277,7 +295,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);
Expand Down
Loading