fix(cli): publish projects through staged uploads (#491)

* fix(cli): publish projects through staged uploads

* fix(cli): honor signed upload length header
This commit is contained in:
Miguel Ángel
2026-04-25 17:09:16 +02:00
committed by GitHub
parent 8ac46b4596
commit b92341eb98
2 changed files with 352 additions and 34 deletions
+134 -14
View File
@@ -37,9 +37,38 @@ describe("createPublishArchive", () => {
describe("publishProjectArchive", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
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: {
@@ -52,16 +81,9 @@ describe("publishProjectArchive", () => {
}),
{ status: 200 },
),
),
);
});
);
vi.stubGlobal("fetch", fetchMock);
afterEach(() => {
vi.unstubAllGlobals();
});
it("uploads the archive and returns the stable project URL", async () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");
@@ -73,8 +95,73 @@ describe("publishProjectArchive", () => {
projectId: "hfp_123",
url: "https://hyperframes.dev/p/hfp_123",
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
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",
@@ -86,4 +173,37 @@ describe("publishProjectArchive", () => {
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 });
}
});
});