mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
## Summary - Replace the flat 30s upload timeout with a size-adaptive calculation: `max(120s, bytes / 500KB/s)` - Metadata requests (presigned URL, complete) keep the original 30s timeout - Companion to the backend change removing the 64 MB upload limit in experiment-framework ## Context With the backend size limit removed, large projects (78 MB+) need proportionally longer to upload. A 78 MB project now gets ~164s, a 500 MB project ~17 min. The old 30s timeout would abort any upload over ~15 MB on a typical connection. ## Test plan - [x] All 4 existing vitest tests pass - [x] Build succeeds, no type errors - [x] Lint + format pass (oxlint + oxfmt) - [x] Timeout values verified for 10/78/200/500/1000 MB archives
227 lines
7.2 KiB
TypeScript
227 lines
7.2 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
createPublishArchive,
|
|
getPublishApiBaseUrl,
|
|
publishProjectArchive,
|
|
uploadTimeoutMs,
|
|
} from "./publishProject.js";
|
|
|
|
function makeProjectDir(): string {
|
|
return mkdtempSync(join(tmpdir(), "hf-publish-"));
|
|
}
|
|
|
|
describe("createPublishArchive", () => {
|
|
it("packages the project and skips hidden files and node_modules", () => {
|
|
const dir = makeProjectDir();
|
|
try {
|
|
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
|
mkdirSync(join(dir, "assets"));
|
|
writeFileSync(join(dir, "assets/logo.svg"), "<svg />", "utf-8");
|
|
mkdirSync(join(dir, ".git"));
|
|
writeFileSync(join(dir, ".env"), "SECRET=1", "utf-8");
|
|
mkdirSync(join(dir, "node_modules"));
|
|
writeFileSync(join(dir, "node_modules/ignored.js"), "console.log('ignore')", "utf-8");
|
|
|
|
const archive = createPublishArchive(dir);
|
|
|
|
expect(archive.fileCount).toBe(2);
|
|
expect(archive.buffer.byteLength).toBeGreaterThan(0);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
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", "");
|
|
vi.stubEnv("HEYGEN_API_URL", "");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("uploads through the staged publish flow and returns the stable project URL", async () => {
|
|
const dir = makeProjectDir();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
upload_url:
|
|
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
|
|
upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
|
|
upload_headers: {
|
|
"content-type": "application/zip",
|
|
"x-amz-server-side-encryption": "AES256",
|
|
},
|
|
content_type: "application/zip",
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
project_id: "hfp_123",
|
|
title: "demo",
|
|
file_count: 2,
|
|
url: "https://hyperframes.dev/p/hfp_123",
|
|
claim_token: "claim-token",
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
try {
|
|
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
|
writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");
|
|
|
|
const result = await publishProjectArchive(dir);
|
|
|
|
expect(getPublishApiBaseUrl()).toBe("https://api2.heygen.com");
|
|
expect(result).toMatchObject({
|
|
projectId: "hfp_123",
|
|
url: "https://hyperframes.dev/p/hfp_123",
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
"https://api2.heygen.com/v1/hyperframes/projects/publish/upload",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", heygen_route: "canary" },
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
|
|
expect.objectContaining({
|
|
method: "PUT",
|
|
headers: {
|
|
"content-length": expect.any(String),
|
|
"content-type": "application/zip",
|
|
"x-amz-server-side-encryption": "AES256",
|
|
},
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
"https://api2.heygen.com/v1/hyperframes/projects/publish/complete",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", heygen_route: "canary" },
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("falls back to the legacy multipart endpoint when staged publish is not deployed", async () => {
|
|
const dir = makeProjectDir();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
project_id: "hfp_123",
|
|
title: "demo",
|
|
file_count: 2,
|
|
url: "https://hyperframes.dev/p/hfp_123",
|
|
claim_token: "claim-token",
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
try {
|
|
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
|
|
|
const result = await publishProjectArchive(dir);
|
|
|
|
expect(result.projectId).toBe("hfp_123");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
"https://api2.heygen.com/v1/hyperframes/projects/publish",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: { heygen_route: "canary" },
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not fall back to multipart when a staged S3 upload fails", async () => {
|
|
const dir = makeProjectDir();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
upload_url: "https://s3.example.com/upload",
|
|
upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
|
|
upload_headers: {
|
|
"content-type": "application/zip",
|
|
"x-amz-server-side-encryption": "AES256",
|
|
},
|
|
content_type: "application/zip",
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(new Response("denied", { status: 403 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
try {
|
|
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
|
|
|
await expect(publishProjectArchive(dir)).rejects.toThrow("Failed to upload project archive");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|