Files
hyperframes/packages/cli/src/server/portUtils.ts
T
Miguel Ángel 0a9555a0f7 fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)
* 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.
2026-06-29 11:23:46 -07:00

419 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.`,
);
}