mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): stable public URL for hyperframes publish (re-publish updates the same link) (#2363)
* feat(cli): stable-URL re-publish (send owned id, honest UX, --update, team id file) Resolve a stable project id (committed team id > machine store > mint), send it when authenticated so an owned re-publish updates the same URL in place, and persist the server id+url. Report updated-vs-created honestly, add --update to target a project explicitly, write a committable .hyperframes/project.json so a team shares one link, and show the prior URL on re-publish. * test(cli): env-gated E2E round-trip for stable-URL re-publish Publish -> edit -> re-publish asserts one URL with updated content against a live EF (HYPERFRAMES_E2E_API_URL + an authenticated runner); skipped otherwise. * fix(cli): real team space, auth-gate --update/--space, safe team-file write - --space + committed .hyperframes/project.json (projectId+spaceId) send X-Space-Id so a team converges on one link; personal space stays the default for solo users - --update/--space error when unauthenticated, and warn loudly when a resolved-but-invalid token silently downgrades to a new anonymous URL (no more generic-tip-only) - team-file write in its own try/catch so a read-only dir can't fake 'Publish failed' - parseUpdateTarget handles scheme-less URLs + query/hash; X-Space-Id on metadata only (not S3 PUT) - share readJsonRecord across the local + team descriptors * test(cli): e2e team-space convergence + cross-space hijack guard * test(cli): unit-test parseUpdateTarget url shapes (export for test) * fix(cli): warn on committed-team miss too, not just --update
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseUpdateTarget } from "./publish.js";
|
||||
|
||||
describe("parseUpdateTarget", () => {
|
||||
it("extracts the id from a full published URL", () => {
|
||||
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("handles a scheme-less URL (which new URL() rejects)", () => {
|
||||
expect(parseUpdateTarget("hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("strips a trailing query and hash", () => {
|
||||
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123?claim_token=x#frag")).toBe(
|
||||
"hfp_abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a bare id unchanged and trims surrounding whitespace", () => {
|
||||
expect(parseUpdateTarget(" hfp_abc123 ")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("falls back to the last path segment for a non-/p/ URL", () => {
|
||||
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolve } from "node:path";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
|
||||
@@ -9,14 +8,39 @@ import { c } from "../ui/colors.js";
|
||||
import { lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import { publishProjectArchive } from "../utils/publishProject.js";
|
||||
import { tryResolveCredential } from "../auth/index.js";
|
||||
import {
|
||||
ensureProjectId,
|
||||
readProjectLink,
|
||||
readTeamProject,
|
||||
writeTeamProject,
|
||||
} from "../utils/projectLink.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Publish the current project with a public URL", "hyperframes publish"],
|
||||
["Publish a specific directory", "hyperframes publish ./my-video"],
|
||||
["Make the claimed project public to anyone", "hyperframes publish --public"],
|
||||
["Update an existing published project in place", "hyperframes publish --update <url|id>"],
|
||||
["Publish to a shared team space", "hyperframes publish --space <space-id>"],
|
||||
["Skip the consent prompt (scripts)", "hyperframes publish --yes"],
|
||||
];
|
||||
|
||||
/** Extract a project id from a published URL (with or without scheme, query, or hash) or accept a bare id. */
|
||||
export function parseUpdateTarget(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
// Pull the id straight out of a `/p/<id>` path — works for full URLs, scheme-less URLs
|
||||
// (which `new URL` rejects), and links carrying `?query`/`#hash`.
|
||||
const pathMatch = trimmed.match(/\/p\/([^/?#]+)/);
|
||||
if (pathMatch?.[1]) return pathMatch[1];
|
||||
try {
|
||||
const segment = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
|
||||
if (segment) return segment;
|
||||
} catch {
|
||||
// Not a URL — treat as a bare id.
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "publish",
|
||||
@@ -35,6 +59,14 @@ export default defineCommand({
|
||||
description: "Make the claimed project public to anyone, not just the claimer",
|
||||
default: false,
|
||||
},
|
||||
update: {
|
||||
type: "string",
|
||||
description: "Update an existing published project in place (its URL or id)",
|
||||
},
|
||||
space: {
|
||||
type: "string",
|
||||
description: "Publish into a shared team space (its id) so teammates update one link",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const rawArg = args.dir;
|
||||
@@ -67,25 +99,119 @@ export default defineCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const updateTarget =
|
||||
typeof args.update === "string" && args.update.trim()
|
||||
? parseUpdateTarget(args.update)
|
||||
: undefined;
|
||||
const spaceOverride =
|
||||
typeof args.space === "string" && args.space.trim() ? args.space.trim() : undefined;
|
||||
|
||||
// --update / --space only take effect for an authenticated owner. Fail loudly rather
|
||||
// than silently minting a fresh URL — the exact failure mode this feature removes.
|
||||
if (updateTarget || spaceOverride) {
|
||||
const credential = await tryResolveCredential();
|
||||
if (!credential) {
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.error(`${updateTarget ? "--update" : "--space"} requires authentication. Run 'hyperframes auth login' first.`)}`,
|
||||
);
|
||||
console.log();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const committedTeam = readTeamProject(dir);
|
||||
// Stable id: explicit --update wins, else the committed team id, else this machine's stored/minted id.
|
||||
const requestedProjectId = updateTarget ?? committedTeam?.projectId ?? ensureProjectId(dir);
|
||||
// Team space: explicit --space wins, else the committed space id, else the personal space.
|
||||
const spaceId = spaceOverride ?? committedTeam?.spaceId;
|
||||
|
||||
// Continuity cue: if this directory was published before, show where it lives so the
|
||||
// user knows a re-publish updates that same link (when logged in).
|
||||
const priorLink = readProjectLink(dir);
|
||||
if (priorLink?.url) {
|
||||
console.log();
|
||||
console.log(` ${c.dim(`Previously published at ${priorLink.url}`)}`);
|
||||
}
|
||||
|
||||
clack.intro(c.bold("hyperframes publish"));
|
||||
const publishSpinner = clack.spinner();
|
||||
publishSpinner.start("Uploading project...");
|
||||
|
||||
try {
|
||||
const published = await publishProjectArchive(dir, { public: args.public === true });
|
||||
const claimUrl = new URL(published.url);
|
||||
claimUrl.searchParams.set("claim_token", published.claimToken);
|
||||
const published = await publishProjectArchive(dir, {
|
||||
public: args.public === true,
|
||||
projectId: requestedProjectId,
|
||||
spaceId,
|
||||
});
|
||||
publishSpinner.stop(c.success("Project published"));
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(published.title)}`);
|
||||
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
|
||||
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
if (published.claimed) {
|
||||
// The server returns the same id on an in-place update, a fresh id on create.
|
||||
const updatedInPlace = published.projectId === requestedProjectId;
|
||||
console.log(` ${c.dim("URL")} ${c.accent(published.url)}`);
|
||||
console.log(
|
||||
` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`,
|
||||
);
|
||||
// Warn whenever we aimed at a KNOWN existing project (an explicit --update target or
|
||||
// a committed team id) but the server created a fresh one instead — so a teammate
|
||||
// whose space doesn't own the committed project doesn't silently lose the shared link.
|
||||
if ((updateTarget || committedTeam) && !updatedInPlace) {
|
||||
const targetDesc = updateTarget ? "The requested project" : "The committed team project";
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim(`${targetDesc} was not updated (not found, or your space can't access it); a new project was created above instead.`)}`,
|
||||
);
|
||||
}
|
||||
// Persist a committable descriptor so a team converges on this link. This is a
|
||||
// convenience: wrap it so a read-only project dir can't turn a successful publish
|
||||
// into a "Publish failed" (the outer catch owns publish failures only).
|
||||
if (
|
||||
committedTeam === null ||
|
||||
(spaceId !== undefined && committedTeam.spaceId !== spaceId)
|
||||
) {
|
||||
try {
|
||||
const file = writeTeamProject(dir, { projectId: published.projectId, spaceId });
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim(`Wrote ${relative(dir, file) || file} — commit it so your team publishes to this link.`)}`,
|
||||
);
|
||||
} catch {
|
||||
// Convenience file only; never shadow a successful publish with a local write failure.
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
} else {
|
||||
const claimUrl = new URL(published.url);
|
||||
claimUrl.searchParams.set("claim_token", published.claimToken);
|
||||
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
|
||||
console.log();
|
||||
if (updateTarget || spaceOverride) {
|
||||
// The pre-publish gate saw a credential, but the server didn't accept it (expired
|
||||
// or invalid) and fell back to anonymous — say so loudly instead of pretending the
|
||||
// requested update happened.
|
||||
console.log(
|
||||
` ${c.error(`Your login looks expired or invalid, so ${updateTarget ? "--update" : "--space"} was ignored and a NEW url was created above.`)}`,
|
||||
);
|
||||
console.log(
|
||||
` ${c.dim("Run 'hyperframes auth login' again, then re-publish to update in place.")}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim("Tip: run 'hyperframes auth login' first for a stable link you can re-publish to.")}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
publishSpinner.stop(c.error("Publish failed"));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Point the module's ~/.hyperframes config dir at a throwaway home so tests use real fs
|
||||
// without touching the developer's actual home directory.
|
||||
const osState = vi.hoisted(() => ({ home: "" }));
|
||||
vi.mock("node:os", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:os")>();
|
||||
return { ...actual, homedir: () => osState.home };
|
||||
});
|
||||
|
||||
describe("projectLink", () => {
|
||||
let projectsPath: string;
|
||||
let projectDirs: string[];
|
||||
let ensureProjectId: typeof import("./projectLink.js").ensureProjectId;
|
||||
let readProjectLink: typeof import("./projectLink.js").readProjectLink;
|
||||
let writeProjectLink: typeof import("./projectLink.js").writeProjectLink;
|
||||
let readTeamProject: typeof import("./projectLink.js").readTeamProject;
|
||||
let writeTeamProject: typeof import("./projectLink.js").writeTeamProject;
|
||||
|
||||
beforeEach(async () => {
|
||||
osState.home = mkdtempSync(join(tmpdir(), "hf-home-"));
|
||||
projectsPath = join(osState.home, ".hyperframes", "projects.json");
|
||||
projectDirs = [];
|
||||
vi.resetModules();
|
||||
({ ensureProjectId, readProjectLink, writeProjectLink, readTeamProject, writeTeamProject } =
|
||||
await import("./projectLink.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(osState.home, { recursive: true, force: true });
|
||||
for (const dir of projectDirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeProjectDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-proj-"));
|
||||
projectDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("mints one project id per directory and reuses it", () => {
|
||||
const first = makeProjectDir();
|
||||
const second = makeProjectDir();
|
||||
const firstId = ensureProjectId(first);
|
||||
|
||||
expect(ensureProjectId(first)).toBe(firstId);
|
||||
expect(ensureProjectId(second)).not.toBe(firstId);
|
||||
});
|
||||
|
||||
it("round-trips a project link", () => {
|
||||
const dir = makeProjectDir();
|
||||
const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123" };
|
||||
|
||||
writeProjectLink(dir, link);
|
||||
|
||||
expect(readProjectLink(dir)).toEqual(link);
|
||||
});
|
||||
|
||||
it("persists only the project id and URL", () => {
|
||||
const dir = makeProjectDir();
|
||||
const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123", secret: "nope" };
|
||||
|
||||
writeProjectLink(dir, link);
|
||||
|
||||
expect(readFileSync(projectsPath, "utf-8")).not.toContain("nope");
|
||||
});
|
||||
|
||||
it("treats a missing projects file as empty and creates it on ensure", () => {
|
||||
const dir = makeProjectDir();
|
||||
expect(readProjectLink(dir)).toBeNull();
|
||||
|
||||
const projectId = ensureProjectId(dir);
|
||||
|
||||
expect(projectId).toBeTruthy();
|
||||
expect(readProjectLink(dir)).toEqual({ projectId, url: "" });
|
||||
});
|
||||
|
||||
it("treats corrupt JSON as empty and rewrites it on ensure", () => {
|
||||
const dir = makeProjectDir();
|
||||
writeProjectLink(dir, { projectId: "x", url: "y" });
|
||||
writeFileSync(projectsPath, "{not valid json", "utf-8");
|
||||
expect(readProjectLink(dir)).toBeNull();
|
||||
|
||||
const projectId = ensureProjectId(dir);
|
||||
|
||||
expect(readProjectLink(dir)).toEqual({ projectId, url: "" });
|
||||
expect(() => JSON.parse(readFileSync(projectsPath, "utf-8"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses the resolved directory as the storage key", () => {
|
||||
const dir = makeProjectDir();
|
||||
const projectId = ensureProjectId(dir);
|
||||
|
||||
expect(ensureProjectId(resolve(dir))).toBe(projectId);
|
||||
expect(Object.keys(JSON.parse(readFileSync(projectsPath, "utf-8")))).toEqual([resolve(dir)]);
|
||||
});
|
||||
|
||||
it("reads and writes a committed team project (id + optional space)", () => {
|
||||
const dir = makeProjectDir();
|
||||
expect(readTeamProject(dir)).toBeNull();
|
||||
|
||||
// Personal-space project: id only, no spaceId key.
|
||||
const soloFile = writeTeamProject(dir, { projectId: "hfp_solo" });
|
||||
expect(readTeamProject(dir)).toEqual({ projectId: "hfp_solo" });
|
||||
const soloBody = readFileSync(soloFile, "utf-8");
|
||||
expect(soloBody).not.toContain("spaceId");
|
||||
// Never a secret.
|
||||
expect(soloBody).not.toContain("token");
|
||||
|
||||
// Team-space project: id + shared space id round-trip.
|
||||
writeTeamProject(dir, { projectId: "hfp_team", spaceId: "space-42" });
|
||||
expect(readTeamProject(dir)).toEqual({ projectId: "hfp_team", spaceId: "space-42" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface ProjectLink {
|
||||
projectId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type ProjectLinks = Record<string, ProjectLink>;
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
||||
const PROJECTS_FILE = join(CONFIG_DIR, "projects.json");
|
||||
|
||||
/** Read + parse a JSON object file, or null if it's missing, unreadable, or not an object. */
|
||||
function readJsonRecord(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
if (!existsSync(path)) return null;
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isProjectLink(value: unknown): value is ProjectLink {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const link = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof link.projectId === "string" && link.projectId.length > 0 && typeof link.url === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function readProjectLinks(): ProjectLinks {
|
||||
const record = readJsonRecord(PROJECTS_FILE);
|
||||
if (!record) return {};
|
||||
const links: ProjectLinks = {};
|
||||
for (const [path, value] of Object.entries(record)) {
|
||||
if (isProjectLink(value)) {
|
||||
links[path] = { projectId: value.projectId, url: value.url };
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function writeProjectLinks(links: ProjectLinks): void {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(PROJECTS_FILE, `${JSON.stringify(links, null, 2)}\n`, { mode: 0o600 });
|
||||
} catch {
|
||||
// Project links must never prevent local CLI commands from running.
|
||||
}
|
||||
}
|
||||
|
||||
export function readProjectLink(absDir: string): ProjectLink | null {
|
||||
return readProjectLinks()[resolve(absDir)] ?? null;
|
||||
}
|
||||
|
||||
export function writeProjectLink(absDir: string, link: ProjectLink): void {
|
||||
const links = readProjectLinks();
|
||||
links[resolve(absDir)] = { projectId: link.projectId, url: link.url };
|
||||
writeProjectLinks(links);
|
||||
}
|
||||
|
||||
export function ensureProjectId(absDir: string): string {
|
||||
const links = readProjectLinks();
|
||||
const path = resolve(absDir);
|
||||
const existing = links[path];
|
||||
if (existing) return existing.projectId;
|
||||
|
||||
const projectId = randomUUID();
|
||||
links[path] = { projectId, url: "" };
|
||||
writeProjectLinks(links);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// A committed, in-project descriptor so a whole team publishes to one shared link. Holds
|
||||
// the project id and (for team spaces) the space id — never a secret; ownership is
|
||||
// enforced server-side by the authenticated space's membership.
|
||||
const TEAM_PROJECT_DIR = ".hyperframes";
|
||||
const TEAM_PROJECT_FILE = "project.json";
|
||||
|
||||
export interface TeamProject {
|
||||
projectId: string;
|
||||
/** Shared team space id. Absent for a personal-space project (resolves per-user). */
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
function teamProjectPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), TEAM_PROJECT_DIR, TEAM_PROJECT_FILE);
|
||||
}
|
||||
|
||||
export function readTeamProject(projectDir: string): TeamProject | null {
|
||||
const record = readJsonRecord(teamProjectPath(projectDir));
|
||||
if (!record || typeof record.projectId !== "string" || record.projectId.length === 0) return null;
|
||||
const spaceId =
|
||||
typeof record.spaceId === "string" && record.spaceId.length > 0 ? record.spaceId : undefined;
|
||||
return { projectId: record.projectId, ...(spaceId ? { spaceId } : {}) };
|
||||
}
|
||||
|
||||
/** Write the committed team descriptor and return its path (for a "commit this" hint). */
|
||||
export function writeTeamProject(projectDir: string, team: TeamProject): string {
|
||||
const file = teamProjectPath(projectDir);
|
||||
const body: TeamProject = {
|
||||
projectId: team.projectId,
|
||||
...(team.spaceId ? { spaceId: team.spaceId } : {}),
|
||||
};
|
||||
mkdirSync(join(resolve(projectDir), TEAM_PROJECT_DIR), { recursive: true });
|
||||
writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPublishApiBaseUrl, publishProjectArchive } from "./publishProject.js";
|
||||
|
||||
// End-to-end round-trip against a LIVE experiment-framework instance. The unit tests mock
|
||||
// the DAO, so only this test proves the real behavior: publish -> edit -> re-publish returns
|
||||
// the SAME URL with updated content.
|
||||
//
|
||||
// It is skipped unless HYPERFRAMES_E2E_API_URL is set, and it requires the runner to be
|
||||
// authenticated first (`hyperframes auth login`, or a HEYGEN credential the auth store can
|
||||
// resolve) — stable URLs are an owner-only capability. Run it manually or in a nightly job
|
||||
// against canary/staging with a test account:
|
||||
//
|
||||
// HYPERFRAMES_E2E_API_URL=https://api2.heygen.com \
|
||||
// bunx vitest run src/utils/publishProject.e2e.test.ts
|
||||
const E2E_API_URL = process.env["HYPERFRAMES_E2E_API_URL"];
|
||||
const describeE2E = E2E_API_URL ? describe : describe.skip;
|
||||
|
||||
describeE2E("publish stable-URL round trip (live server)", () => {
|
||||
const priorPublishBase = process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"];
|
||||
|
||||
beforeAll(() => {
|
||||
// Point the publish client at the E2E target for the duration of this suite.
|
||||
process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] = E2E_API_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (priorPublishBase === undefined)
|
||||
delete process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"];
|
||||
else process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] = priorPublishBase;
|
||||
});
|
||||
|
||||
async function fetchPublicProject(projectId: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(
|
||||
`${getPublishApiBaseUrl()}/v1/hyperframes/projects/${projectId}/public`,
|
||||
{ headers: { heygen_route: "canary" } },
|
||||
);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = (await response.json()) as { data: Record<string, unknown> };
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
it("re-publishing the same project keeps one URL and serves the updated content", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-e2e-"));
|
||||
try {
|
||||
// First authenticated publish → the server creates and owns the project.
|
||||
writeFileSync(join(dir, "index.html"), "<html><body>version one</body></html>", "utf-8");
|
||||
const first = await publishProjectArchive(dir);
|
||||
// If this fails, the runner is not authenticated — stable URLs require an owner.
|
||||
expect(first.claimed).toBe(true);
|
||||
|
||||
// Edit locally, then re-publish targeting the owned project id.
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
"<html><body>version two</body><p>added</p></html>",
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(dir, "extra.html"), "<html>second page</html>", "utf-8");
|
||||
const second = await publishProjectArchive(dir, { projectId: first.projectId });
|
||||
|
||||
// Same project, same URL — the whole point.
|
||||
expect(second.projectId).toBe(first.projectId);
|
||||
expect(second.url).toBe(first.url);
|
||||
expect(second.claimed).toBe(true);
|
||||
|
||||
// The live public project reflects the second publish (extra file included).
|
||||
const publicData = await fetchPublicProject(second.projectId);
|
||||
expect(publicData["project_id"]).toBe(first.projectId);
|
||||
expect(Number(publicData["file_count"])).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("two identities in a shared team space converge on one URL, and other spaces can't hijack it", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-e2e-team-"));
|
||||
const originalKey = process.env["HYPERFRAMES_API_KEY"];
|
||||
try {
|
||||
// Member A publishes into the shared team space.
|
||||
writeFileSync(join(dir, "index.html"), "<html>team v1</html>", "utf-8");
|
||||
process.env["HYPERFRAMES_API_KEY"] = "e2e-team-member-a";
|
||||
const a = await publishProjectArchive(dir, { spaceId: "team-e2e" });
|
||||
expect(a.claimed).toBe(true);
|
||||
|
||||
// A DIFFERENT member (different credential) re-publishes the same project id in the
|
||||
// same space → converges on the same URL.
|
||||
writeFileSync(join(dir, "index.html"), "<html>team v2</html>", "utf-8");
|
||||
process.env["HYPERFRAMES_API_KEY"] = "e2e-team-member-b";
|
||||
const b = await publishProjectArchive(dir, { projectId: a.projectId, spaceId: "team-e2e" });
|
||||
expect(b.projectId).toBe(a.projectId);
|
||||
expect(b.url).toBe(a.url);
|
||||
|
||||
// A member of a DIFFERENT space cannot overwrite it → gets a fresh project.
|
||||
process.env["HYPERFRAMES_API_KEY"] = "e2e-outsider";
|
||||
const c = await publishProjectArchive(dir, {
|
||||
projectId: a.projectId,
|
||||
spaceId: "other-space",
|
||||
});
|
||||
expect(c.projectId).not.toBe(a.projectId);
|
||||
} finally {
|
||||
if (originalKey === undefined) delete process.env["HYPERFRAMES_API_KEY"];
|
||||
else process.env["HYPERFRAMES_API_KEY"] = originalKey;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -4,6 +4,22 @@ import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
tryResolveCredential: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/index.js", () => ({
|
||||
tryResolveCredential: authMocks.tryResolveCredential,
|
||||
}));
|
||||
|
||||
const linkMocks = vi.hoisted(() => ({
|
||||
writeProjectLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./projectLink.js", () => ({
|
||||
writeProjectLink: linkMocks.writeProjectLink,
|
||||
}));
|
||||
|
||||
import {
|
||||
createPublishArchive,
|
||||
getPublishApiBaseUrl,
|
||||
@@ -16,6 +32,160 @@ function makeProjectDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "hf-publish-"));
|
||||
}
|
||||
|
||||
/** Writes an external asset and returns its path relative to `fromDir`, POSIX-slashed. */
|
||||
function stageExternalAsset(
|
||||
extDir: string,
|
||||
fromDir: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
): string {
|
||||
writeFileSync(join(extDir, filename), content, "utf-8");
|
||||
return relative(fromDir, join(extDir, filename)).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
/** A single-entry files map for `localizeExternalAssets`, keyed on index.html. */
|
||||
function indexHtmlFiles(html: string): Map<string, Buffer> {
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages one external asset in a fresh project/ext dir pair, builds the
|
||||
* files map from it via `buildFiles`, runs `localizeExternalAssets`, and
|
||||
* cleans up both dirs. Covers the single-external-asset test shape shared
|
||||
* by most `localizeExternalAssets` cases.
|
||||
*/
|
||||
function localizeSingleAsset(
|
||||
assetName: string,
|
||||
assetContent: string,
|
||||
buildFiles: (relToExt: string, projectDir: string) => Map<string, Buffer>,
|
||||
): { count: number; files: Map<string, Buffer>; relToExt: string } {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
const relToExt = stageExternalAsset(extDir, projectDir, assetName, assetContent);
|
||||
const files = buildFiles(relToExt, projectDir);
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
return { count, files, relToExt };
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `localizeExternalAssets`, asserts nothing was rewritten, and returns the (unchanged) index.html. */
|
||||
function expectNoLocalization(projectDir: string, files: Map<string, Buffer>): string {
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
expect(count).toBe(0);
|
||||
return files.get("index.html")!.toString("utf-8");
|
||||
}
|
||||
|
||||
/** Asserts a `localizeSingleAsset` result rewrote index.html exactly once, and returns it. */
|
||||
function expectLocalizedHtml(result: {
|
||||
count: number;
|
||||
files: Map<string, Buffer>;
|
||||
relToExt: string;
|
||||
}): string {
|
||||
expect(result.count).toBe(1);
|
||||
const rewrittenHtml = result.files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain("_ext/");
|
||||
expect(rewrittenHtml).not.toContain(result.relToExt);
|
||||
return rewrittenHtml;
|
||||
}
|
||||
|
||||
/** The staged `/publish/upload` success response, overridable per test. */
|
||||
function uploadResponse(overrides?: Record<string, unknown>): Response {
|
||||
return 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" },
|
||||
content_type: "application/zip",
|
||||
...overrides,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
/** The `/publish/complete` (or legacy `/publish`) success response, overridable per test. */
|
||||
function publishedResponse(overrides?: Record<string, unknown>): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
project_id: "hfp_123",
|
||||
title: "demo",
|
||||
file_count: 1,
|
||||
url: "https://hyperframes.dev/p/hfp_123",
|
||||
claim_token: "claim-token",
|
||||
...overrides,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Full staged flow: upload, S3 PUT, complete. */
|
||||
function stagedFetch(completeData?: Record<string, unknown>, uploadData?: Record<string, unknown>) {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(uploadResponse(uploadData))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValueOnce(publishedResponse(completeData));
|
||||
}
|
||||
|
||||
/** Legacy multipart flow: staged upload 404s, falls back to direct publish. */
|
||||
function directFetch(completeData?: Record<string, unknown>) {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(publishedResponse(completeData));
|
||||
}
|
||||
|
||||
/** Asserts the Nth fetch call, always requiring an AbortSignal alongside the given init. */
|
||||
function expectFetchCall(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
n: number,
|
||||
url: string,
|
||||
init: Record<string, unknown>,
|
||||
): void {
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
n,
|
||||
url,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal), ...init }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves `tryResolveCredential` to a stubbed OAuth token for authenticated-request tests. */
|
||||
function withOAuthCredential(): void {
|
||||
authMocks.tryResolveCredential.mockResolvedValue({
|
||||
type: "oauth",
|
||||
access_token: "test-token",
|
||||
source: "file_json",
|
||||
refreshable: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Asserts the given fetch call carried the stubbed bearer token. */
|
||||
function expectAuthorizedHeaders(fetchMock: ReturnType<typeof vi.fn>, callIndex: number): void {
|
||||
expect(fetchMock.mock.calls[callIndex]![1].headers).toEqual(
|
||||
expect.objectContaining({ authorization: "Bearer test-token" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stubs an OAuth credential and fetch, then publishes a minimal project through `dir`. */
|
||||
async function runAuthenticatedPublish(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
dir: string,
|
||||
): Promise<void> {
|
||||
withOAuthCredential();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
await publishProjectArchive(dir);
|
||||
}
|
||||
|
||||
describe("createPublishArchive", () => {
|
||||
it("packages the project and skips hidden files and node_modules", () => {
|
||||
const dir = makeProjectDir();
|
||||
@@ -40,78 +210,40 @@ describe("createPublishArchive", () => {
|
||||
|
||||
describe("localizeExternalAssets", () => {
|
||||
it("copies external src/href assets and rewrites HTML paths", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "logo.png"), "PNG_DATA", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "logo.png")).replaceAll("\\", "/");
|
||||
const result = localizeSingleAsset("logo.png", "PNG_DATA", (rel) =>
|
||||
indexHtmlFiles(`<html><body><img src="${rel}"></body></html>`),
|
||||
);
|
||||
expectLocalizedHtml(result);
|
||||
|
||||
const html = `<html><body><img src="${relToExt}"></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(1);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).not.toContain(relToExt);
|
||||
expect(rewrittenHtml).toContain("_ext/");
|
||||
|
||||
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
|
||||
expect(extEntries).toHaveLength(1);
|
||||
expect(files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA");
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
const extEntries = [...result.files.keys()].filter((k) => k.startsWith("_ext/"));
|
||||
expect(extEntries).toHaveLength(1);
|
||||
expect(result.files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA");
|
||||
});
|
||||
|
||||
it("rewrites CSS url() in <style> blocks", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
|
||||
const result = localizeSingleAsset("bg.jpg", "JPEG_DATA", (rel) =>
|
||||
indexHtmlFiles(
|
||||
`<html><head><style>body { background: url("${rel}"); }</style></head></html>`,
|
||||
),
|
||||
);
|
||||
|
||||
const html = `<html><head><style>body { background: url("${relToExt}"); }</style></head></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(1);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain("url(");
|
||||
expect(rewrittenHtml).toContain("_ext/");
|
||||
expect(rewrittenHtml).not.toContain(relToExt);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
const rewrittenHtml = expectLocalizedHtml(result);
|
||||
expect(rewrittenHtml).toContain("url(");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeExternalAssets", () => {
|
||||
it("rewrites url() in standalone CSS files", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "font.woff2"), "FONT_DATA", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "font.woff2")).replaceAll("\\", "/");
|
||||
const { count, files, relToExt } = localizeSingleAsset("font.woff2", "FONT_DATA", (rel) => {
|
||||
const files = indexHtmlFiles("<html></html>");
|
||||
files.set("styles.css", Buffer.from(`@font-face { src: url("${rel}"); }`, "utf-8"));
|
||||
return files;
|
||||
});
|
||||
|
||||
const css = `@font-face { src: url("${relToExt}"); }`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
|
||||
files.set("styles.css", Buffer.from(css, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(1);
|
||||
const rewrittenCss = files.get("styles.css")!.toString("utf-8");
|
||||
expect(rewrittenCss).toContain("_ext/");
|
||||
expect(rewrittenCss).not.toContain(relToExt);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(count).toBe(1);
|
||||
const rewrittenCss = files.get("styles.css")!.toString("utf-8");
|
||||
expect(rewrittenCss).toContain("_ext/");
|
||||
expect(rewrittenCss).not.toContain(relToExt);
|
||||
});
|
||||
|
||||
it("leaves internal assets unchanged", () => {
|
||||
@@ -119,33 +251,29 @@ describe("localizeExternalAssets", () => {
|
||||
try {
|
||||
mkdirSync(join(projectDir, "assets"));
|
||||
writeFileSync(join(projectDir, "assets", "logo.svg"), "<svg/>", "utf-8");
|
||||
const html = `<html><body><img src="assets/logo.svg"></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
const files = indexHtmlFiles(`<html><body><img src="assets/logo.svg"></body></html>`);
|
||||
files.set("assets/logo.svg", Buffer.from("<svg/>", "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
const rewrittenHtml = expectNoLocalization(projectDir, files);
|
||||
|
||||
expect(count).toBe(0);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain('src="assets/logo.svg"');
|
||||
expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeExternalAssets", () => {
|
||||
it("leaves remote URLs unchanged", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
try {
|
||||
const html = `<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
const files = indexHtmlFiles(
|
||||
`<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`,
|
||||
);
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
const rewrittenHtml = expectNoLocalization(projectDir, files);
|
||||
|
||||
expect(count).toBe(0);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain("https://cdn.example.com/logo.png");
|
||||
expect(rewrittenHtml).toContain("http://cdn.example.com/vid.mp4");
|
||||
} finally {
|
||||
@@ -153,33 +281,48 @@ describe("localizeExternalAssets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates: same external asset referenced from multiple files", () => {
|
||||
it("no-op when no external assets exist", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "shared.png"), "SHARED", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "shared.png")).replaceAll("\\", "/");
|
||||
const files = indexHtmlFiles(`<html><body><p>Hello</p></body></html>`);
|
||||
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set(
|
||||
"index.html",
|
||||
Buffer.from(`<html><body><img src="${relToExt}"></body></html>`, "utf-8"),
|
||||
);
|
||||
expectNoLocalization(projectDir, files);
|
||||
|
||||
expect(files.size).toBe(1);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips references to non-existent external files", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
try {
|
||||
const files = indexHtmlFiles(`<html><body><img src="../nonexistent/file.png"></body></html>`);
|
||||
|
||||
const rewrittenHtml = expectNoLocalization(projectDir, files);
|
||||
|
||||
expect(rewrittenHtml).toContain("../nonexistent/file.png");
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeExternalAssets", () => {
|
||||
it("deduplicates: same external asset referenced from multiple files", () => {
|
||||
const { count, files } = localizeSingleAsset("shared.png", "SHARED", (rel, projectDir) => {
|
||||
const files = indexHtmlFiles(`<html><body><img src="${rel}"></body></html>`);
|
||||
mkdirSync(join(projectDir, "compositions"));
|
||||
files.set(
|
||||
"compositions/scene.html",
|
||||
Buffer.from(`<html><body><img src="../${relToExt}"></body></html>`, "utf-8"),
|
||||
Buffer.from(`<html><body><img src="../${rel}"></body></html>`, "utf-8"),
|
||||
);
|
||||
return files;
|
||||
});
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(1);
|
||||
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
|
||||
expect(extEntries).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(count).toBe(1);
|
||||
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
|
||||
expect(extEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles sub-composition HTML with external refs", () => {
|
||||
@@ -187,14 +330,14 @@ describe("localizeExternalAssets", () => {
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
mkdirSync(join(projectDir, "compositions"));
|
||||
writeFileSync(join(extDir, "overlay.png"), "OVERLAY", "utf-8");
|
||||
const relFromComps = relative(
|
||||
const relFromComps = stageExternalAsset(
|
||||
extDir,
|
||||
join(projectDir, "compositions"),
|
||||
join(extDir, "overlay.png"),
|
||||
).replaceAll("\\", "/");
|
||||
"overlay.png",
|
||||
"OVERLAY",
|
||||
);
|
||||
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
|
||||
const files = indexHtmlFiles("<html></html>");
|
||||
files.set(
|
||||
"compositions/scene.html",
|
||||
Buffer.from(`<html><body><img src="${relFromComps}"></body></html>`, "utf-8"),
|
||||
@@ -211,69 +354,24 @@ describe("localizeExternalAssets", () => {
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("no-op when no external assets exist", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
try {
|
||||
const html = `<html><body><p>Hello</p></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(files.size).toBe(1);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips references to non-existent external files", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
try {
|
||||
const html = `<html><body><img src="../nonexistent/file.png"></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(0);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain("../nonexistent/file.png");
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("localizeExternalAssets", () => {
|
||||
it("rewrites inline style url() references", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
|
||||
const result = localizeSingleAsset("bg.jpg", "JPEG_DATA", (rel) =>
|
||||
indexHtmlFiles(
|
||||
`<html><body><div style="background-image: url('${rel}')"></div></body></html>`,
|
||||
),
|
||||
);
|
||||
|
||||
const html = `<html><body><div style="background-image: url('${relToExt}')"></div></body></html>`;
|
||||
const files = new Map<string, Buffer>();
|
||||
files.set("index.html", Buffer.from(html, "utf-8"));
|
||||
|
||||
const count = localizeExternalAssets(projectDir, files);
|
||||
|
||||
expect(count).toBe(1);
|
||||
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
|
||||
expect(rewrittenHtml).toContain("_ext/");
|
||||
expect(rewrittenHtml).not.toContain(relToExt);
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(extDir, { recursive: true, force: true });
|
||||
}
|
||||
expectLocalizedHtml(result);
|
||||
});
|
||||
|
||||
it("createPublishArchive includes localized external assets", () => {
|
||||
const projectDir = makeProjectDir();
|
||||
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
|
||||
try {
|
||||
writeFileSync(join(extDir, "video.mp4"), "MP4_DATA", "utf-8");
|
||||
const relToExt = relative(projectDir, join(extDir, "video.mp4")).replaceAll("\\", "/");
|
||||
const relToExt = stageExternalAsset(extDir, projectDir, "video.mp4", "MP4_DATA");
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<html><body><video src="${relToExt}"></video></body></html>`,
|
||||
@@ -314,53 +412,37 @@ describe("uploadTimeoutMs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Shared across every `publishProjectArchive` group below (file-scoped, not
|
||||
// nested in a describe) so the reset logic isn't duplicated per group.
|
||||
beforeEach(() => {
|
||||
authMocks.tryResolveCredential.mockReset().mockResolvedValue(null);
|
||||
linkMocks.writeProjectLink.mockReset();
|
||||
vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
|
||||
vi.stubEnv("HEYGEN_API_URL", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const jsonHeaders = { "content-type": "application/json", heygen_route: "canary" };
|
||||
const signedStagedS3Url =
|
||||
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption";
|
||||
|
||||
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 },
|
||||
),
|
||||
);
|
||||
const fetchMock = stagedFetch(
|
||||
{ file_count: 2 },
|
||||
{
|
||||
upload_url: signedStagedS3Url,
|
||||
upload_headers: {
|
||||
"content-type": "application/zip",
|
||||
"x-amz-server-side-encryption": "AES256",
|
||||
},
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
@@ -375,61 +457,75 @@ describe("publishProjectArchive", () => {
|
||||
url: "https://hyperframes.dev/p/hfp_123",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
expectFetchCall(
|
||||
fetchMock,
|
||||
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),
|
||||
}),
|
||||
headers: jsonHeaders,
|
||||
},
|
||||
);
|
||||
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(
|
||||
expectFetchCall(fetchMock, 2, signedStagedS3Url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-length": expect.any(String),
|
||||
"content-type": "application/zip",
|
||||
"x-amz-server-side-encryption": "AES256",
|
||||
},
|
||||
});
|
||||
expectFetchCall(
|
||||
fetchMock,
|
||||
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),
|
||||
}),
|
||||
headers: jsonHeaders,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishProjectArchive", () => {
|
||||
it("authenticates staged metadata requests but not the presigned S3 upload", async () => {
|
||||
const dir = makeProjectDir();
|
||||
const fetchMock = stagedFetch();
|
||||
|
||||
try {
|
||||
await runAuthenticatedPublish(fetchMock, dir);
|
||||
|
||||
expect(authMocks.tryResolveCredential).toHaveBeenCalledTimes(1);
|
||||
expectAuthorizedHeaders(fetchMock, 0);
|
||||
expect(fetchMock.mock.calls[1]![1].headers).not.toHaveProperty("authorization");
|
||||
expectAuthorizedHeaders(fetchMock, 2);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticates the direct fallback request", async () => {
|
||||
const dir = makeProjectDir();
|
||||
const fetchMock = directFetch();
|
||||
|
||||
try {
|
||||
await runAuthenticatedPublish(fetchMock, dir);
|
||||
|
||||
expect(authMocks.tryResolveCredential).toHaveBeenCalledTimes(1);
|
||||
expectAuthorizedHeaders(fetchMock, 0);
|
||||
expectAuthorizedHeaders(fetchMock, 1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishProjectArchive", () => {
|
||||
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 },
|
||||
),
|
||||
);
|
||||
const fetchMock = directFetch({ file_count: 2 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
@@ -439,15 +535,10 @@ describe("publishProjectArchive", () => {
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
expectFetchCall(fetchMock, 2, "https://api2.heygen.com/v1/hyperframes/projects/publish", {
|
||||
method: "POST",
|
||||
headers: { heygen_route: "canary" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -455,37 +546,6 @@ describe("publishProjectArchive", () => {
|
||||
|
||||
it("sends is_public in the staged complete body only when public is requested", async () => {
|
||||
const dir = makeProjectDir();
|
||||
const stagedFetch = () =>
|
||||
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" },
|
||||
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: 1,
|
||||
url: "https://hyperframes.dev/p/hfp_123",
|
||||
claim_token: "claim-token",
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
@@ -505,27 +565,11 @@ describe("publishProjectArchive", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishProjectArchive", () => {
|
||||
it("sends is_public in the direct multipart form only when public is requested", async () => {
|
||||
const dir = makeProjectDir();
|
||||
const directFetch = () =>
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
project_id: "hfp_123",
|
||||
title: "demo",
|
||||
file_count: 1,
|
||||
url: "https://hyperframes.dev/p/hfp_123",
|
||||
claim_token: "claim-token",
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
@@ -551,20 +595,12 @@ describe("publishProjectArchive", () => {
|
||||
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 },
|
||||
),
|
||||
uploadResponse({
|
||||
upload_headers: {
|
||||
"content-type": "application/zip",
|
||||
"x-amz-server-side-encryption": "AES256",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("denied", { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -579,3 +615,90 @@ describe("publishProjectArchive", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Staged flow returning an already-owned, already-claimed stable project. */
|
||||
function ownedStagedFetch() {
|
||||
return stagedFetch({
|
||||
project_id: "hfp_stable",
|
||||
url: "https://hyperframes.dev/p/hfp_stable",
|
||||
claim_token: "",
|
||||
claimed: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("publishProjectArchive", () => {
|
||||
it("sends and persists a stable project id when authenticated", async () => {
|
||||
withOAuthCredential();
|
||||
const dir = makeProjectDir();
|
||||
const fetchMock = ownedStagedFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
|
||||
const result = await publishProjectArchive(dir, { projectId: "hfp_stable" });
|
||||
|
||||
const completeBody = JSON.parse(fetchMock.mock.calls[2]![1].body);
|
||||
expect(completeBody.project_id).toBe("hfp_stable");
|
||||
// An owned re-publish returns claimed with no claim token to append.
|
||||
expect(result.claimed).toBe(true);
|
||||
expect(result.claimToken).toBe("");
|
||||
expect(linkMocks.writeProjectLink).toHaveBeenCalledWith(dir, {
|
||||
projectId: "hfp_stable",
|
||||
url: "https://hyperframes.dev/p/hfp_stable",
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("never sends a project id or persists a link when anonymous", async () => {
|
||||
// Default credential is null (anonymous).
|
||||
const dir = makeProjectDir();
|
||||
const fetchMock = ownedStagedFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
|
||||
await publishProjectArchive(dir, { projectId: "hfp_stable" });
|
||||
|
||||
const completeBody = JSON.parse(fetchMock.mock.calls[2]![1].body);
|
||||
expect(completeBody).not.toHaveProperty("project_id");
|
||||
expect(linkMocks.writeProjectLink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function publishWithTeamSpace(authed: boolean) {
|
||||
// Set explicitly (not just for the authed case) so calling this twice in one test —
|
||||
// authed then anonymous — doesn't leak the credential mock across calls.
|
||||
if (authed) withOAuthCredential();
|
||||
else authMocks.tryResolveCredential.mockResolvedValue(null);
|
||||
const dir = makeProjectDir();
|
||||
const fetchMock = ownedStagedFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
try {
|
||||
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
|
||||
await publishProjectArchive(dir, { projectId: "hfp_stable", spaceId: "space-42" });
|
||||
return fetchMock;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("sends X-Space-Id only when authenticated (metadata requests, never the S3 PUT)", async () => {
|
||||
const authed = await publishWithTeamSpace(true);
|
||||
// Metadata calls (upload=0, complete=2) carry the team space header...
|
||||
expect(authed.mock.calls[0]![1].headers["x-space-id"]).toBe("space-42");
|
||||
expect(authed.mock.calls[2]![1].headers["x-space-id"]).toBe("space-42");
|
||||
// ...but the presigned S3 PUT (1) must NOT — extra headers break the signature.
|
||||
expect(authed.mock.calls[1]![1].headers).not.toHaveProperty("x-space-id");
|
||||
|
||||
// Anonymous: the header is dropped entirely even if a space was requested.
|
||||
const anon = await publishWithTeamSpace(false);
|
||||
expect(anon.mock.calls[0]![1].headers).not.toHaveProperty("x-space-id");
|
||||
expect(anon.mock.calls[2]![1].headers).not.toHaveProperty("x-space-id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { parseHTML } from "linkedom";
|
||||
import AdmZip from "adm-zip";
|
||||
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
|
||||
import { buildAuthHeaders } from "../auth/client.js";
|
||||
import { tryResolveCredential } from "../auth/index.js";
|
||||
import { writeProjectLink } from "./projectLink.js";
|
||||
|
||||
const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]);
|
||||
const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]);
|
||||
@@ -24,6 +27,8 @@ export interface PublishedProjectResponse {
|
||||
fileCount: number;
|
||||
url: string;
|
||||
claimToken: string;
|
||||
/** True when the project is owned by the authenticated publisher (created-and-owned or updated in place). */
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
interface StagedUploadResponse {
|
||||
@@ -56,9 +61,14 @@ function parsePublishedProjectResponse(payload: unknown): PublishedProjectRespon
|
||||
const projectId = stringField(data, "project_id");
|
||||
const title = stringField(data, "title");
|
||||
const url = stringField(data, "url");
|
||||
const claimToken = stringField(data, "claim_token");
|
||||
const claimToken = stringField(data, "claim_token") ?? "";
|
||||
const claimed = data["claimed"] === true;
|
||||
const fileCount = data["file_count"];
|
||||
if (!projectId || !title || !url || !claimToken || typeof fileCount !== "number") {
|
||||
if (!projectId || !title || !url || typeof fileCount !== "number") {
|
||||
return null;
|
||||
}
|
||||
// Anonymous publishes must return a claim token; owned (claimed) ones need none.
|
||||
if (!claimed && !claimToken) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -67,6 +77,7 @@ function parsePublishedProjectResponse(payload: unknown): PublishedProjectRespon
|
||||
fileCount,
|
||||
url,
|
||||
claimToken,
|
||||
claimed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -384,15 +395,19 @@ async function publishProjectArchiveDirect(
|
||||
title: string,
|
||||
archive: PublishArchiveResult,
|
||||
isPublic: boolean,
|
||||
authHeaders: Record<string, string>,
|
||||
projectId: string | undefined,
|
||||
): Promise<PublishedProjectResponse> {
|
||||
const body = new FormData();
|
||||
body.set("title", title);
|
||||
if (isPublic) body.set("is_public", "true");
|
||||
if (projectId) body.set("project_id", projectId);
|
||||
body.set(
|
||||
"file",
|
||||
new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }),
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...authHeaders,
|
||||
heygen_route: "canary",
|
||||
};
|
||||
|
||||
@@ -412,11 +427,31 @@ async function publishProjectArchiveDirect(
|
||||
return publishedProject;
|
||||
}
|
||||
|
||||
async function uploadArchiveToPresignedUrl(
|
||||
stagedUpload: StagedUploadResponse,
|
||||
archive: PublishArchiveResult,
|
||||
): Promise<void> {
|
||||
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(
|
||||
Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs),
|
||||
),
|
||||
});
|
||||
if (!s3Response.ok) {
|
||||
throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive"));
|
||||
}
|
||||
}
|
||||
|
||||
async function publishProjectArchiveStaged(
|
||||
apiBaseUrl: string,
|
||||
title: string,
|
||||
archive: PublishArchiveResult,
|
||||
isPublic: boolean,
|
||||
authHeaders: Record<string, string>,
|
||||
projectId: string | undefined,
|
||||
): Promise<PublishedProjectResponse | null> {
|
||||
const fileName = `${title}.zip`;
|
||||
const uploadResponse = await fetch(`${apiBaseUrl}/v1/hyperframes/projects/publish/upload`, {
|
||||
@@ -427,6 +462,7 @@ async function publishProjectArchiveStaged(
|
||||
content_length: archive.buffer.byteLength,
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"content-type": "application/json",
|
||||
heygen_route: "canary",
|
||||
},
|
||||
@@ -443,18 +479,7 @@ 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(
|
||||
Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs),
|
||||
),
|
||||
});
|
||||
if (!s3Response.ok) {
|
||||
throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive"));
|
||||
}
|
||||
await uploadArchiveToPresignedUrl(stagedUpload, archive);
|
||||
|
||||
const completeResponse = await fetch(`${apiBaseUrl}/v1/hyperframes/projects/publish/complete`, {
|
||||
method: "POST",
|
||||
@@ -463,8 +488,10 @@ async function publishProjectArchiveStaged(
|
||||
file_name: fileName,
|
||||
title,
|
||||
...(isPublic ? { is_public: true } : {}),
|
||||
...(projectId ? { project_id: projectId } : {}),
|
||||
}),
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"content-type": "application/json",
|
||||
heygen_route: "canary",
|
||||
},
|
||||
@@ -482,6 +509,10 @@ async function publishProjectArchiveStaged(
|
||||
|
||||
export interface PublishOptions {
|
||||
public?: boolean;
|
||||
/** Stable project id to update in place. Only sent when authenticated. */
|
||||
projectId?: string;
|
||||
/** Shared team space id, sent as X-Space-Id so team members converge. Only when authenticated. */
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export async function publishProjectArchive(
|
||||
@@ -492,7 +523,35 @@ export async function publishProjectArchive(
|
||||
const title = basename(projectDir);
|
||||
const archive = createPublishArchive(projectDir);
|
||||
const apiBaseUrl = getPublishApiBaseUrl();
|
||||
const stagedResult = await publishProjectArchiveStaged(apiBaseUrl, title, archive, isPublic);
|
||||
if (stagedResult) return stagedResult;
|
||||
return publishProjectArchiveDirect(apiBaseUrl, title, archive, isPublic);
|
||||
const credential = await tryResolveCredential();
|
||||
const authHeaders = credential ? buildAuthHeaders(credential) : {};
|
||||
// A stable id / team space only mean something to an authenticated owner — the server
|
||||
// ignores them otherwise, and anonymous publishes always mint a fresh project.
|
||||
const projectId = credential ? opts.projectId : undefined;
|
||||
const spaceId = credential ? opts.spaceId : undefined;
|
||||
// X-Space-Id rides with the auth headers on the metadata requests only (never the
|
||||
// presigned S3 PUT), so the server resolves the shared team space instead of the personal one.
|
||||
const metadataHeaders = spaceId ? { ...authHeaders, "x-space-id": spaceId } : authHeaders;
|
||||
const result =
|
||||
(await publishProjectArchiveStaged(
|
||||
apiBaseUrl,
|
||||
title,
|
||||
archive,
|
||||
isPublic,
|
||||
metadataHeaders,
|
||||
projectId,
|
||||
)) ??
|
||||
(await publishProjectArchiveDirect(
|
||||
apiBaseUrl,
|
||||
title,
|
||||
archive,
|
||||
isPublic,
|
||||
metadataHeaders,
|
||||
projectId,
|
||||
));
|
||||
// Remember the server's id + url so the next publish of this directory updates in place.
|
||||
if (credential) {
|
||||
writeProjectLink(projectDir, { projectId: result.projectId, url: result.url });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user