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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/cli/src/commands/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { studioLandingSearch } from "./preview.js";

const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

function projectWith(storyboard: string | null, frameFiles: string[] = []): string {
const dir = mkdtempSync(join(tmpdir(), "hf-preview-landing-"));
tempDirs.push(dir);
if (storyboard !== null) writeFileSync(join(dir, "STORYBOARD.md"), storyboard);
for (const file of frameFiles) {
mkdirSync(join(dir, file, ".."), { recursive: true });
writeFileSync(join(dir, file), "<div></div>");
}
return dir;
}

const FRAME = (n: number, status: string) =>
`## Frame ${n} — F${n}\n- status: ${status}\n- src: compositions/frames/0${n}.html\n\nBeat.\n`;

describe("studioLandingSearch", () => {
it("returns no search without a storyboard", () => {
expect(studioLandingSearch(projectWith(null))).toBe("");
});

it("lands on the board while sketches are under review (any built frame)", () => {
const dir = projectWith(`${FRAME(1, "built")}${FRAME(2, "outline")}`, [
"compositions/frames/01.html",
]);
expect(studioLandingSearch(dir)).toBe("?view=storyboard");
});

it("lands on the board during pure planning (srcs declared, none exist)", () => {
const dir = projectWith(`${FRAME(1, "outline")}${FRAME(2, "outline")}`);
expect(studioLandingSearch(dir)).toBe("?view=storyboard");
});

it("lands on the timeline once frames exist without a built status", () => {
const dir = projectWith(`${FRAME(1, "outline")}`, ["compositions/frames/01.html"]);
expect(studioLandingSearch(dir)).toBe("");
});

it("lands on the timeline for fully animated boards", () => {
const dir = projectWith(`${FRAME(1, "animated")}`, ["compositions/frames/01.html"]);
expect(studioLandingSearch(dir)).toBe("");
});
});
64 changes: 56 additions & 8 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ export const examples: Example[] = [
["List all active preview servers", "hyperframes preview --list"],
["Kill all active preview servers", "hyperframes preview --kill-all"],
];
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { parseStoryboard, STORYBOARD_FILENAME } from "@hyperframes/core/storyboard";
import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
Expand Down Expand Up @@ -635,9 +644,47 @@ function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSel
};
}

function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
// Land the browser on the Storyboard view while the project is still planning
// or sketching — the timeline only becomes the right landing once frames are
// animated (or the storyboard never tracked statuses at all, e.g. beat plans).
export function studioLandingSearch(projectDir: string): string {
const storyboardPath = join(projectDir, STORYBOARD_FILENAME);
if (!existsSync(storyboardPath)) return "";
let frames;
try {
frames = parseStoryboard(readFileSync(storyboardPath, "utf8")).frames;
} catch {
return "";
}
// Sketch review in progress — the board is the review surface.
if (frames.some((f) => f.status === "built")) return "?view=storyboard";
// Pure planning stage: frames declare src paths but none are built yet.
const srcs = frames
.map((f) => f.src)
.filter((s): s is string => typeof s === "string" && s.length > 0);
const planning =
frames.length > 0 &&
frames.every((f) => f.status === "outline") &&
srcs.length > 0 &&
!srcs.some((s) => existsSync(join(projectDir, s)));
return planning ? "?view=storyboard" : "";
}

// The full Studio URL to open or hand to the user: status-aware landing view
// plus the project hash route. `url` never carries a trailing slash (both the
// embedded server and the Vite `Local:` match strip it).
function studioDeepLink(url: string, projectName: string, projectDir: string): string {
return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`;
}

function openStudioBrowser(
url: string,
projectName: string,
projectDir: string,
options?: BrowserLaunchOptions,
): void {
if (options?.noOpen) return;
openBrowser(`${url}#project/${projectName}`, {
openBrowser(studioDeepLink(url, projectName, projectDir), {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
Expand Down Expand Up @@ -719,6 +766,7 @@ function attachStudioReadyHandler(
child: StudioChildProcess,
spinner: ReturnType<typeof clack.spinner>,
projectName: string,
projectDir: string,
options?: BrowserLaunchOptions,
): void {
let detected = false;
Expand All @@ -730,7 +778,7 @@ function attachStudioReadyHandler(
detected = true;
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
openStudioBrowser(url, projectName, options);
openStudioBrowser(url, projectName, projectDir, options);
child.stdout.removeListener("data", handleOutput);
child.stderr.removeListener("data", handleOutput);
}
Expand Down Expand Up @@ -768,7 +816,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
stdio: ["ignore", "pipe", "pipe"],
});

attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);

// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
Expand Down Expand Up @@ -817,7 +865,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P
stdio: ["ignore", "pipe", "pipe"],
});

attachStudioReadyHandler(child, s, pName, options);
attachStudioReadyHandler(child, s, pName, dir, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);

// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
Expand Down Expand Up @@ -888,7 +936,7 @@ async function runEmbeddedMode(
printStudioSummary(pName, url, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);
return;
}

Expand All @@ -906,7 +954,7 @@ async function runEmbeddedMode(
],
footer: "Press Ctrl+C to stop",
});
openStudioBrowser(url, pName, options);
openStudioBrowser(url, pName, dir, options);

// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
// HTTP server keeps handles open, so the event loop stays alive after the
Expand Down
20 changes: 20 additions & 0 deletions packages/studio-server/src/helpers/projectSignature.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { lstatSync, readFileSync, readdirSync } from "node:fs";
import { extname, isAbsolute, relative, resolve } from "node:path";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";

const SIGNATURE_TEXT_EXTENSIONS = new Set([
".cjs",
Expand Down Expand Up @@ -137,6 +138,25 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil
return hash.digest("hex").slice(0, 24);
}

/**
* Resolve the project signature through the adapter's cached path when the host
* provides one (the CLI invalidates its cache from the file watcher), falling
* back to a direct computation.
*/
export function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
}

/** The shared route opening: resolve the project (null → caller 404s) with its signature. */
export async function resolveProjectAndSignature(
adapter: StudioApiAdapter,
projectId: string,
): Promise<{ project: ResolvedProject; signature: string } | null> {
const project = await adapter.resolveProject(projectId);
if (!project) return null;
return { project, signature: resolveProjectSignature(adapter, project.dir) };
}

/**
* Creates a stable preview cache-busting signature for project source plus Studio manifests.
*/
Expand Down
22 changes: 10 additions & 12 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
import { createProjectSignature } from "../helpers/projectSignature.js";
import {
resolveProjectAndSignature,
resolveProjectSignature,
} from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
Expand All @@ -22,10 +25,6 @@ const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_C
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;

function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
}

function injectProjectSignature(html: string, signature: string): string {
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
Expand Down Expand Up @@ -309,15 +308,15 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
// Bundled composition preview
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
if (!resolved) return c.json({ error: "not found" }, 404);
const { project, signature } = resolved;

// fallow-ignore-next-line code-duplication
const vars = previewVariablesFromRequest(c.req.query("variables"));
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
const previewVariables = vars.values;

const signature = resolveProjectSignature(adapter, project.dir);
const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
Expand Down Expand Up @@ -422,15 +421,14 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
// Sub-composition preview
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
if (!resolved) return c.json({ error: "not found" }, 404);
const { project, signature } = resolved;

// fallow-ignore-next-line code-duplication
const vars = previewVariablesFromRequest(c.req.query("variables"));
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
const previewVariables = vars.values;

const signature = resolveProjectSignature(adapter, project.dir);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
Expand Down
44 changes: 44 additions & 0 deletions packages/studio-server/src/routes/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,50 @@ function createAdapter(projectDir: string): StudioApiAdapter {
};
}

describe("GET /projects/:id/signature", () => {
it("returns the adapter's cached signature when provided", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, {
...createAdapter(projectDir),
getProjectSignature: () => "cached-sig",
});

const response = await app.request("http://localhost/projects/demo/signature");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ signature: "cached-sig" });
});

it("computes a signature that moves when project files change", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));

const first = (await (
await app.request("http://localhost/projects/demo/signature")
).json()) as {
signature: string;
};
expect(first.signature).toMatch(/^[0-9a-f]{24}$/);

writeFileSync(join(projectDir, "compositions", "scene.html"), "<html><body>new</body></html>");
const second = (await (
await app.request("http://localhost/projects/demo/signature")
).json()) as { signature: string };
expect(second.signature).not.toBe(first.signature);
});

it("404s for an unknown project", async () => {
const app = new Hono();
registerProjectRoutes(app, {
...createAdapter(createProjectDir()),
resolveProject: async () => null,
});
const response = await app.request("http://localhost/projects/nope/signature");
expect(response.status).toBe(404);
});
});

describe("registerProjectRoutes — composition discovery (#1384)", () => {
it("excludes HTML inside dot-directories from compositions", async () => {
const projectDir = createProjectDir();
Expand Down
10 changes: 10 additions & 0 deletions packages/studio-server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
import { resolveProjectSignature } from "../helpers/projectSignature.js";

const COMPOSITION_ID_RE = /data-composition-id\s*=/;

Expand Down Expand Up @@ -39,6 +40,15 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
return c.json(result);
});

// Current content signature for a project — a cheap poll target for clients
// that refresh themselves when files change on disk (the storyboard board
// re-fetches when this differs from the signature its data was loaded with).
api.get("/projects/:id/signature", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
return c.json({ signature: resolveProjectSignature(adapter, project.dir) });
});

// Project file tree
api.get("/projects/:id", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
Expand Down
13 changes: 13 additions & 0 deletions packages/studio-server/src/routes/storyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,17 @@ Not built yet.
const { body } = await getStoryboard(dir);
expect(body.frames[0].srcExists).toBe(false);
});

it("carries a project signature in both the absent and present branches", async () => {
const dir = makeProject();
const absent = await getStoryboard(dir);
expect(absent.body.exists).toBe(false);
expect(absent.body.signature).toMatch(/^[0-9a-f]{24}$/);

writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n");
const present = await getStoryboard(dir);
expect(present.body.exists).toBe(true);
expect(present.body.signature).toMatch(/^[0-9a-f]{24}$/);
expect(present.body.signature).not.toBe(absent.body.signature);
});
});
Loading
Loading