mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
* fix(studio): reconcile external edits before reload * fix(ci): retry transient workspace installs Make external reload retry behavior honest and isolate reload listeners. Remove the dead SDK timestamp parameter.
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { openComposition } from "@hyperframes/sdk";
|
|
import type { Composition } from "@hyperframes/sdk";
|
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
|
/**
|
|
* Read a project file's content, or undefined on a non-2xx (optional read).
|
|
* Replaces the removed SDK http adapter's `read()` — the only thing Studio used
|
|
* it for (Studio is the sole writer, so the adapter's write path was dead).
|
|
*/
|
|
async function readProjectFileOptional(
|
|
projectId: string,
|
|
path: string,
|
|
): Promise<string | undefined> {
|
|
// Reject traversal / NUL before building the request URL — `path` is a
|
|
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
// already confines both values to single segments of this same-origin URL.
|
|
if (path.includes("\0") || path.includes("..")) return undefined;
|
|
const res = await fetch(
|
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
);
|
|
if (!res.ok) return undefined;
|
|
const data = (await res.json()) as { content?: string };
|
|
return typeof data.content === "string" ? data.content : undefined;
|
|
}
|
|
|
|
/**
|
|
* True when an external file-change payload targets the active composition and
|
|
* the SDK session must be re-opened to pick up the new content.
|
|
*/
|
|
export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean {
|
|
if (!activeCompPath) return false;
|
|
return readStudioFileChangePath(payload) === activeCompPath;
|
|
}
|
|
|
|
/**
|
|
* Stage 7 Step 3a — SDK session wired to the active composition.
|
|
*
|
|
* Creates an SDK Composition (reading the file via the project files API) on
|
|
* every (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
* re-opens it when the active composition file changes on disk (code editor,
|
|
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
* the open effect below.
|
|
*/
|
|
/**
|
|
* Decide whether a file-change for the active composition should reload the SDK
|
|
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
|
* pass null when unavailable. Content-identity wins: a change whose bytes match a
|
|
* registered self-write is our own echo (suppress). Without content we can't prove
|
|
* identity, so we fall back to the time window ONLY to suppress an echo — an undo
|
|
* write outside the window (or any non-self-write) still reloads. Exported for test.
|
|
*/
|
|
export function shouldReloadOnFileChange(
|
|
activeCompPath: string,
|
|
content: string | null,
|
|
withinSuppressWindow: boolean,
|
|
): boolean {
|
|
if (content != null) return !isSelfWriteEcho(activeCompPath, content);
|
|
// No content to compare — preserve the old time-window echo suppression.
|
|
return !withinSuppressWindow;
|
|
}
|
|
|
|
export interface SdkSessionHandle {
|
|
session: Composition | null;
|
|
/** Atomically publish a fully persisted candidate session. */
|
|
publish: PublishSdkSession;
|
|
/**
|
|
* Force a session reload immediately, bypassing the self-write suppress
|
|
* window. Call after undo/redo writes the active composition file so the
|
|
* SDK in-memory document reflects the reverted content.
|
|
*/
|
|
forceReload: () => void;
|
|
}
|
|
|
|
interface SdkSessionOwner {
|
|
projectId: string;
|
|
path: string;
|
|
reloadToken: number;
|
|
generation: number;
|
|
}
|
|
|
|
interface OwnedSdkSession extends SdkSessionOwner {
|
|
session: Composition;
|
|
}
|
|
|
|
function isSessionOwnerActive(
|
|
owner: SdkSessionOwner | undefined,
|
|
projectId: string | null,
|
|
path: string | null,
|
|
targetPath: string,
|
|
): owner is SdkSessionOwner {
|
|
if (!owner) return false;
|
|
return owner.projectId === projectId && owner.path === path && owner.path === targetPath;
|
|
}
|
|
|
|
function isSessionOwnerCurrent(
|
|
owner: SdkSessionOwner,
|
|
generation: number,
|
|
projectId: string | null,
|
|
path: string | null,
|
|
reloadToken: number,
|
|
): boolean {
|
|
return (
|
|
owner.generation === generation &&
|
|
owner.projectId === projectId &&
|
|
owner.path === path &&
|
|
owner.reloadToken === reloadToken
|
|
);
|
|
}
|
|
|
|
function ownsExpectedSession(
|
|
current: OwnedSdkSession | null,
|
|
expectedOwner: SdkSessionOwner,
|
|
expectedSession: Composition,
|
|
reloadToken: number,
|
|
): current is OwnedSdkSession {
|
|
if (!current) return false;
|
|
return (
|
|
current.session === expectedSession &&
|
|
current.generation === expectedOwner.generation &&
|
|
current.reloadToken === reloadToken
|
|
);
|
|
}
|
|
|
|
function disposeSdkSession(session: Composition): void {
|
|
try {
|
|
session.dispose();
|
|
} catch (error) {
|
|
trackStudioEvent("sdk_session_dispose_failed", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
export function useSdkSession(
|
|
projectId: string | null,
|
|
activeCompPath: string | null,
|
|
): SdkSessionHandle {
|
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
const sessionOwnersRef = useRef(new WeakMap<Composition, SdkSessionOwner>());
|
|
const generationRef = useRef(0);
|
|
const projectIdRef = useRef(projectId);
|
|
projectIdRef.current = projectId;
|
|
const activeCompPathRef = useRef(activeCompPath);
|
|
activeCompPathRef.current = activeCompPath;
|
|
const [reloadToken, setReloadToken] = useState(0);
|
|
const reloadTokenRef = useRef(reloadToken);
|
|
reloadTokenRef.current = reloadToken;
|
|
|
|
useEffect(
|
|
() =>
|
|
addExternalFileReloadListener((changedPath) => {
|
|
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
|
|
}),
|
|
[],
|
|
);
|
|
|
|
// ── Open / re-open the session ──
|
|
useEffect(() => {
|
|
const generation = ++generationRef.current;
|
|
let cancelled = false;
|
|
|
|
// The preceding effect normally released its generation first. Clear any
|
|
// remaining owner defensively so an invalid project/path cannot retain it.
|
|
const previous = ownedSessionRef.current;
|
|
ownedSessionRef.current = null;
|
|
setOwnedSession(null);
|
|
if (previous) disposeSdkSession(previous.session);
|
|
|
|
if (!projectId || !activeCompPath) {
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}
|
|
|
|
const owner: SdkSessionOwner = {
|
|
projectId,
|
|
path: activeCompPath,
|
|
reloadToken,
|
|
generation,
|
|
};
|
|
|
|
readProjectFileOptional(projectId, activeCompPath)
|
|
.then(async (content) => {
|
|
if (cancelled || typeof content !== "string") return;
|
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
// queue too would double-write the file (queue auto-writes on every
|
|
// 'change' AND Studio writes explicitly) and race on disk; it would
|
|
// also write the full active-composition serialization to the fixed
|
|
// persistPath even when an edit targeted a sub-composition file.
|
|
// Studio's editHistory is the authoritative undo stack — SDK history
|
|
// is unused dead weight here (forceReloadSdkSession discards it on undo).
|
|
const comp = await openComposition(content, { history: false });
|
|
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
if (cancelled) {
|
|
disposeSdkSession(comp);
|
|
return;
|
|
}
|
|
if (
|
|
!isSessionOwnerCurrent(
|
|
owner,
|
|
generationRef.current,
|
|
projectIdRef.current,
|
|
activeCompPathRef.current,
|
|
reloadTokenRef.current,
|
|
)
|
|
) {
|
|
disposeSdkSession(comp);
|
|
return;
|
|
}
|
|
const displaced = ownedSessionRef.current;
|
|
const installed = { ...owner, session: comp };
|
|
sessionOwnersRef.current.set(comp, owner);
|
|
ownedSessionRef.current = installed;
|
|
setOwnedSession(installed);
|
|
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled && generationRef.current === generation) setOwnedSession(null);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
// Publication preserves this generation, so cleanup releases whichever
|
|
// session it currently owns (the initially opened one or its candidate).
|
|
const owned = ownedSessionRef.current;
|
|
if (owned?.generation === generation) {
|
|
ownedSessionRef.current = null;
|
|
disposeSdkSession(owned.session);
|
|
}
|
|
};
|
|
}, [projectId, activeCompPath, reloadToken]);
|
|
|
|
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
|
const publish = useCallback<PublishSdkSession>(({ candidate, expectedSession, targetPath }) => {
|
|
const expectedOwner = sessionOwnersRef.current.get(expectedSession);
|
|
const current = ownedSessionRef.current;
|
|
if (
|
|
!isSessionOwnerActive(
|
|
expectedOwner,
|
|
projectIdRef.current,
|
|
activeCompPathRef.current,
|
|
targetPath,
|
|
)
|
|
) {
|
|
return "rejected-inactive-target";
|
|
}
|
|
if (!ownsExpectedSession(current, expectedOwner, expectedSession, reloadTokenRef.current)) {
|
|
// The durable write won, but another session was installed for this same
|
|
// path before publication. Its self-write echo will be suppressed, so
|
|
// explicitly re-open it from disk instead of leaving it stale.
|
|
setReloadToken((t) => t + 1);
|
|
return "rejected-active-target";
|
|
}
|
|
const next: OwnedSdkSession = { ...current, session: candidate };
|
|
sessionOwnersRef.current.set(candidate, current);
|
|
ownedSessionRef.current = next;
|
|
setOwnedSession(next);
|
|
if (current.session !== candidate) disposeSdkSession(current.session);
|
|
return "published";
|
|
}, []);
|
|
const session =
|
|
ownedSession?.projectId === projectId &&
|
|
ownedSession.path === activeCompPath &&
|
|
ownedSession.reloadToken === reloadToken
|
|
? ownedSession.session
|
|
: null;
|
|
return { session, publish, forceReload };
|
|
}
|