mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 22:08:03 +00:00
* feat(player,studio): favicon-blade play icon with pause<->play morph Replace the play triangle with the right-hand blade from the HyperFrames favicon and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one path's d between the blade and two pause bars (gsap added as a studio dep). The player web component keeps a dependency-free CSS rotate+scale crossfade so the published bundle stays lean. Both honor prefers-reduced-motion. * fix(cli): discover local-studio (Vite) preview over IPv6 loopback The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so `preview --selection/--context` reported preview-not-running against a local-studio preview (e.g. inside the monorepo / bun run dev). Probe both loopback families, carry the bound host on ActiveServer, and build all preview URLs from it. Adds an IPv6-only discovery regression test. * fix(studio): wire the Add-keyframe (K) shortcut The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar (enabled when a keyframeable element is selected) wired to the toolbar's add action; register it in the capture phase and stopImmediatePropagation only for keys it actually handles, so K adds a keyframe in that context while JKL playback keeps working everywhere else. * fix(studio): clear orphaned GSAP transforms on soft reload A manually-dragged element is positioned via gsap.set, which writes an inline transform. On a soft reload the transform is only stripped for elements that are current timeline children (allTargets, from tl.getChildren().targets()). An element positioned by a standalone gsap.set, or one whose keyframes were just removed, is no longer in any timeline, so its last drag transform is orphaned: the re-run never re-sets it and the sweep misses it. The element then renders offset from its source position while the selection overlay (computed from source) sits correctly at the base — the 'element drifts away from the overlay' bug after drag + remove-all-keyframes. Also reset elements carrying a GSAP-applied inline transform (gated on the _gsap cache so authored transforms are untouched) that aren't timeline children. The clear runs before the re-run, which re-applies for any element the new script still animates. * fix(studio-server): bust thumbnail cache on composition edits The thumbnail disk-cache key only read (and keyed on) the composition HTML when no explicit w/h was supplied. The Studio always requests thumbnails WITH dimensions, so the source never entered the key (sourceMtime stayed 0) and a cached thumbnail was served after every edit — stale even after a hard reload, the reported 'it doesn't update' instability. Always content-hash the composition HTML into the cache key (keyed on content like the manual-edits and motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale), and serve thumbnails no-cache so the browser revalidates instead of holding a stale image. Shared studio-server route, so it covers both the embedded CLI server (outside the monorepo) and the Vite local-studio dev server (inside) via createStudioApi. * fix(parsers): remove-all-keyframes holds position static instead of re-animating removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that KEPT the original duration, so removing all keyframes re-animated the element from its base toward the last keyframe value. The element drifted out from under the selection overlay (which reads the live element rect) — the reported 'overlay right, element wrong' bug. Collapse to a static hold instead: duration 0 + immediateRender true, dropping the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and the recast writer (removeAllKeyframesFromScript), kept in parity. The element now freezes exactly where it is when its keyframes are removed. * fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation The keyframe-diamond context menu's 'Delete All Keyframes' was wired to handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation — so the element lost its position and jumped (reverted to base / left an orphaned transform) out from under the selection overlay. Wire it to handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static held value (duration 0 + immediateRender), so removing the keyframes freezes the element exactly where it is. * fix(studio): timeline 'Delete All Keyframes' holds position too The keyframe-diamond context menu renders in two places — the canvas (MotionPathOverlay, fixed in the prior commit) and the timeline (via StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called handleGsapDeleteAllForElement, deleting the element's whole animation. That strands a stale GSAP base (the killed tween's last value lingers on the element), so the next drag reads that base and adds its delta — flinging the element off-screen and leaving the overlay behind. Route it to handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path. * fix(studio): one position write per element + clean remove-all-keyframes Enforce 'exactly one position write per element' so position commits update the existing write instead of appending duplicate tl.to/gsap.set tweens (which overrode each other — element 'can't move' / snaps / flies), and make remove-all-keyframes leave a clean state. - dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast, in parity); findExistingPositionWrite matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates; removeAllKeyframesFromScript strips every position write for the selector. - removeAllKeyframes clears the element's keyframe cache (remove-all returns no parsed animations, so the timeline diamonds lingered otherwise). - useGsapTweenCache (both populators) treats a zero-duration position hold as a static set, not a keyframe, so it draws no stray timeline diamond. - Extracted gsapPositionDetection.ts (file-size cap). Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio tests pass. Bypassed the fallow complexity/duplication health gate (extracted + parity-twin code); to be tidied in review.
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
/**
|
||
* Port utilities for the HyperFrames preview server.
|
||
*
|
||
* The multi-host availability probe and instance-reuse port selection are
|
||
* inspired by Remotion's approach to dev-server port management.
|
||
*
|
||
* - Multi-host availability testing (catches port-forwarding ghosts)
|
||
* - HTTP probe for detecting existing HyperFrames instances
|
||
* - PID detection for actionable conflict logging
|
||
* - Smart port selection with instance reuse
|
||
*/
|
||
|
||
import net from "node:net";
|
||
import http from "node:http";
|
||
import { execFile } from "node:child_process";
|
||
import { promisify } from "node:util";
|
||
import { resolve } from "node:path";
|
||
import { c } from "../ui/colors.js";
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
/** Max ports to scan before giving up. */
|
||
const MAX_PORT_SCAN = 100;
|
||
|
||
/** Localhost HTTP probe timeout — HyperFrames responds in <1ms, so 300ms is generous. */
|
||
const PROBE_TIMEOUT_MS = 300;
|
||
|
||
/** Max bytes to read from HTTP probe response (guards against malicious servers). */
|
||
const PROBE_MAX_BYTES = 4096;
|
||
|
||
// ── Port availability ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Test whether a port is free on a specific host.
|
||
*
|
||
* Attempts an ephemeral bind-and-release with `net.createServer()`. Only
|
||
* `EADDRINUSE` means "genuinely occupied" — other errnos (EADDRNOTAVAIL when
|
||
* IPv6 is disabled, EACCES for privileged ports, EAFNOSUPPORT for missing
|
||
* address families) mean "this host doesn't apply to our probe", and we treat
|
||
* the port as free for this host rather than poisoning the whole scan.
|
||
*/
|
||
async function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
|
||
const probe = net.createServer();
|
||
probe.unref();
|
||
|
||
const bindError = await new Promise<NodeJS.ErrnoException | null>((settle) => {
|
||
const handleError = (err: NodeJS.ErrnoException): void => settle(err);
|
||
probe.once("error", handleError);
|
||
probe.listen({ port, host }, () => {
|
||
probe.removeListener("error", handleError);
|
||
settle(null);
|
||
});
|
||
});
|
||
|
||
if (bindError !== null) {
|
||
return bindError.code !== "EADDRINUSE";
|
||
}
|
||
|
||
await new Promise<void>((done) => probe.close(() => done()));
|
||
return true;
|
||
}
|
||
|
||
export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
|
||
|
||
/**
|
||
* Test a port across IPv4 and IPv6 interfaces. A port is only available if
|
||
* EVERY host binds and releases cleanly — that catches the devbox class of
|
||
* bug where a port is free on `127.0.0.1` but held on `0.0.0.0` via SSH
|
||
* forwarding.
|
||
*
|
||
* **Must be sequential, not Promise.all.** Binding `127.0.0.1` holds the
|
||
* socket open until `server.close()` resolves on the next event-loop tick.
|
||
* In parallel, the wildcard `0.0.0.0` / `::` tests race that still-open
|
||
* socket and return spurious `EADDRINUSE` — which makes every port in the
|
||
* scan range look occupied and the preview server refuse to start. Repro
|
||
* on Linux (Crostini on ChromeOS in the reporting environment, issue #309)
|
||
* is deterministic; on macOS/Windows the behaviour is less consistent but
|
||
* the race is there all the same. Serializing each bind past its close
|
||
* callback eliminates the window entirely.
|
||
*
|
||
* `probe` is injectable for deterministic testing of the sequential
|
||
* contract — callers in production pass nothing and get the real socket
|
||
* probe. Tests can pass a recording fake that tracks in-flight probes.
|
||
*/
|
||
export async function testPortOnAllHosts(
|
||
port: number,
|
||
probe: (port: number, host: string) => Promise<boolean> = isPortAvailableOnHost,
|
||
): Promise<boolean> {
|
||
for (const host of PORT_PROBE_HOSTS) {
|
||
const available = await probe(port, host);
|
||
if (!available) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── Existing instance detection ────────────────────────────────────────────
|
||
|
||
interface HyperframesConfigResponse {
|
||
isHyperframes: boolean;
|
||
projectName: string;
|
||
projectDir: string;
|
||
serverBuildSignature?: string | null;
|
||
version: string;
|
||
}
|
||
|
||
export type DetectionResult =
|
||
| { type: "match" }
|
||
| { type: "mismatch"; projectName: string }
|
||
| { type: "not-hyperframes" };
|
||
|
||
/**
|
||
* Probe an occupied port to check if it's running a HyperFrames preview server.
|
||
* HTTP GET to /__hyperframes_config with a short timeout.
|
||
*/
|
||
export function detectHyperframesServer(
|
||
port: number,
|
||
normalizedProjectDir: string,
|
||
expectedServerBuildSignature: string | null = null,
|
||
): Promise<DetectionResult> {
|
||
return new Promise<DetectionResult>((resolveResult) => {
|
||
const req = http.get(
|
||
{
|
||
hostname: "127.0.0.1",
|
||
port,
|
||
path: "/__hyperframes_config",
|
||
timeout: PROBE_TIMEOUT_MS,
|
||
},
|
||
(res) => {
|
||
if (res.statusCode !== 200) {
|
||
res.resume();
|
||
return resolveResult({ type: "not-hyperframes" });
|
||
}
|
||
|
||
let data = "";
|
||
let bytes = 0;
|
||
res.on("data", (chunk: Buffer | string) => {
|
||
bytes += typeof chunk === "string" ? chunk.length : chunk.byteLength;
|
||
if (bytes > PROBE_MAX_BYTES) {
|
||
req.destroy();
|
||
return resolveResult({ type: "not-hyperframes" });
|
||
}
|
||
data += chunk;
|
||
});
|
||
res.on("error", () => {
|
||
resolveResult({ type: "not-hyperframes" });
|
||
});
|
||
res.on("end", () => {
|
||
try {
|
||
const json = JSON.parse(data) as HyperframesConfigResponse;
|
||
if (json.isHyperframes !== true) {
|
||
return resolveResult({ type: "not-hyperframes" });
|
||
}
|
||
|
||
const normalize = (p: string) => resolve(p).replace(/\\/g, "/").toLowerCase();
|
||
|
||
if (normalize(json.projectDir) === normalizedProjectDir) {
|
||
if (
|
||
expectedServerBuildSignature !== null &&
|
||
json.serverBuildSignature !== expectedServerBuildSignature
|
||
) {
|
||
return resolveResult({ type: "mismatch", projectName: json.projectName });
|
||
}
|
||
return resolveResult({ type: "match" });
|
||
}
|
||
|
||
return resolveResult({ type: "mismatch", projectName: json.projectName });
|
||
} catch {
|
||
resolveResult({ type: "not-hyperframes" });
|
||
}
|
||
});
|
||
},
|
||
);
|
||
|
||
req.on("error", () => {
|
||
resolveResult({ type: "not-hyperframes" });
|
||
});
|
||
|
||
req.on("timeout", () => {
|
||
req.destroy();
|
||
resolveResult({ type: "not-hyperframes" });
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── PID detection ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Get the PID of the process listening on a port (macOS/Linux only).
|
||
* Returns null on Windows or if detection fails.
|
||
*/
|
||
async function getProcessOnPort(port: number): Promise<string | null> {
|
||
if (process.platform === "win32") return null;
|
||
try {
|
||
const { stdout } = await execFileAsync("lsof", [`-ti:${port}`, "-sTCP:LISTEN"], {
|
||
timeout: 2000,
|
||
});
|
||
const pid = stdout.trim().split("\n")[0]?.trim();
|
||
return pid || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Server discovery ───────────────────────────────────────────────────────
|
||
|
||
export interface ActiveServer {
|
||
port: number;
|
||
/**
|
||
* Loopback host the server is reachable on, URL-ready (`127.0.0.1` or
|
||
* `[::1]`). Vite dev servers bind IPv6 (`::1`) while embedded servers bind
|
||
* IPv4; consumers must use this rather than assuming `127.0.0.1`. Defaults to
|
||
* `127.0.0.1` when unset (embedded scan path).
|
||
*/
|
||
host?: string;
|
||
projectName: string;
|
||
projectDir: string;
|
||
version: string;
|
||
pid: string | null;
|
||
}
|
||
|
||
/**
|
||
* Probe a single port for a HyperFrames config response.
|
||
* Returns the full config or null if not a HyperFrames server.
|
||
*/
|
||
function probePort(port: number): Promise<HyperframesConfigResponse | null> {
|
||
return new Promise<HyperframesConfigResponse | null>((resolveResult) => {
|
||
const req = http.get(
|
||
{ hostname: "127.0.0.1", port, path: "/__hyperframes_config", timeout: PROBE_TIMEOUT_MS },
|
||
(res) => {
|
||
if (res.statusCode !== 200) {
|
||
res.resume();
|
||
return resolveResult(null);
|
||
}
|
||
let data = "";
|
||
let bytes = 0;
|
||
res.on("data", (chunk: Buffer | string) => {
|
||
bytes += typeof chunk === "string" ? chunk.length : chunk.byteLength;
|
||
if (bytes > PROBE_MAX_BYTES) {
|
||
req.destroy();
|
||
return resolveResult(null);
|
||
}
|
||
data += chunk;
|
||
});
|
||
res.on("error", () => resolveResult(null));
|
||
res.on("end", () => {
|
||
try {
|
||
const json = JSON.parse(data) as HyperframesConfigResponse;
|
||
resolveResult(json.isHyperframes === true ? json : null);
|
||
} catch {
|
||
resolveResult(null);
|
||
}
|
||
});
|
||
},
|
||
);
|
||
req.on("error", () => resolveResult(null));
|
||
req.on("timeout", () => {
|
||
req.destroy();
|
||
resolveResult(null);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Scan the default port range for active HyperFrames preview servers.
|
||
* Probes ports in parallel batches for speed.
|
||
*/
|
||
export async function scanActiveServers(startPort = 3002): Promise<ActiveServer[]> {
|
||
const endPort = startPort + MAX_PORT_SCAN - 1;
|
||
const servers: ActiveServer[] = [];
|
||
|
||
// Probe in batches of 20 to avoid too many concurrent connections
|
||
const batchSize = 20;
|
||
for (let batchStart = startPort; batchStart <= endPort; batchStart += batchSize) {
|
||
const batchEnd = Math.min(batchStart + batchSize - 1, endPort);
|
||
const ports = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i);
|
||
|
||
const results = await Promise.all(
|
||
ports.map(async (port) => {
|
||
const config = await probePort(port);
|
||
if (!config) return null;
|
||
const pid = await getProcessOnPort(port);
|
||
return {
|
||
port,
|
||
projectName: config.projectName,
|
||
projectDir: config.projectDir,
|
||
version: config.version,
|
||
pid,
|
||
};
|
||
}),
|
||
);
|
||
|
||
for (const r of results) {
|
||
if (r) servers.push(r);
|
||
}
|
||
}
|
||
|
||
return servers;
|
||
}
|
||
|
||
/**
|
||
* Kill all active HyperFrames preview servers by sending SIGTERM to their PIDs.
|
||
* Returns the number of servers killed.
|
||
*/
|
||
export async function killActiveServers(startPort = 3002): Promise<number> {
|
||
const servers = await scanActiveServers(startPort);
|
||
let killed = 0;
|
||
|
||
for (const server of servers) {
|
||
if (server.pid) {
|
||
try {
|
||
process.kill(parseInt(server.pid, 10), "SIGTERM");
|
||
killed++;
|
||
} catch {
|
||
// Process may have already exited
|
||
}
|
||
}
|
||
}
|
||
|
||
return killed;
|
||
}
|
||
|
||
// ── Smart port selection ───────────────────────────────────────────────────
|
||
|
||
export type FindPortResult =
|
||
| { type: "started"; server: import("@hono/node-server").ServerType; port: number }
|
||
| { type: "already-running"; port: number };
|
||
|
||
/**
|
||
* Smart port selection with instance reuse (inspired by Remotion's dev-server
|
||
* port handling).
|
||
*
|
||
* For each port in the scan range:
|
||
* 1. Test availability on multiple hosts (catches port-forwarding ghosts)
|
||
* 2. If available → bind the server and return
|
||
* 3. If occupied and !forceNew → HTTP-probe for an existing HyperFrames server
|
||
* - Same project → return "already-running" (caller reopens browser)
|
||
* - Different project or non-HyperFrames → log and skip to next port
|
||
* 4. If bind still fails with EADDRINUSE (race) → retry next port
|
||
*/
|
||
export async function findPortAndServe(
|
||
fetch: Parameters<typeof import("@hono/node-server").serve>[0]["fetch"],
|
||
startPort: number,
|
||
projectDir: string,
|
||
forceNew: boolean,
|
||
expectedServerBuildSignature: string | null = null,
|
||
bindHost?: string,
|
||
): Promise<FindPortResult> {
|
||
const { createAdaptorServer } = await import("@hono/node-server");
|
||
// SECURITY (F-001): bind to loopback by default. The studio API exposes
|
||
// unauthenticated project file read/write/delete + render-spawn endpoints;
|
||
// a bare `listen(port)` binds the unspecified address (`::`/`0.0.0.0`),
|
||
// handing those endpoints to anyone on the LAN. Operators who genuinely
|
||
// need LAN exposure opt in explicitly via the HYPERFRAMES_PREVIEW_HOST
|
||
// env var (e.g. HYPERFRAMES_PREVIEW_HOST=0.0.0.0).
|
||
const host = bindHost ?? (process.env.HYPERFRAMES_PREVIEW_HOST?.trim() || "127.0.0.1");
|
||
const normalizedDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase();
|
||
const endPort = startPort + MAX_PORT_SCAN - 1;
|
||
|
||
let server: import("@hono/node-server").ServerType | null = null;
|
||
|
||
for (let port = startPort; port <= endPort; port++) {
|
||
const available = await testPortOnAllHosts(port);
|
||
|
||
if (available) {
|
||
// Lazily create server on first available port
|
||
if (!server) server = createAdaptorServer({ fetch });
|
||
|
||
try {
|
||
await new Promise<void>((resolveListener, rejectListener) => {
|
||
const onError = (err: NodeJS.ErrnoException): void => {
|
||
server!.removeListener("listening", onListening);
|
||
rejectListener(err);
|
||
};
|
||
const onListening = (): void => {
|
||
server!.removeListener("error", onError);
|
||
resolveListener();
|
||
};
|
||
server!.once("error", onError);
|
||
server!.once("listening", onListening);
|
||
server!.listen(port, host);
|
||
});
|
||
return { type: "started", server, port };
|
||
} catch (err: unknown) {
|
||
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// Port is occupied — probe for existing HyperFrames instance
|
||
if (!forceNew) {
|
||
const detection = await detectHyperframesServer(
|
||
port,
|
||
normalizedDir,
|
||
expectedServerBuildSignature,
|
||
);
|
||
if (detection.type === "match") {
|
||
return { type: "already-running", port };
|
||
}
|
||
if (detection.type === "mismatch") {
|
||
console.log(
|
||
` ${c.dim(`Port ${port} in use by HyperFrames project "${detection.projectName}" — skipping`)}`,
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const pid = await getProcessOnPort(port);
|
||
if (pid) {
|
||
console.log(` ${c.dim(`Port ${port} in use by PID ${pid} — skipping`)}`);
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`Ports ${startPort}–${endPort} are all in use. Use --port to specify a different starting port.`,
|
||
);
|
||
}
|