mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
## What
Foundations of the `@hyperframes/core/figma` module — the pure, transport-agnostic layer every later phase builds on:
- **`types.ts`** — `FigmaRef`, `FigmaProvenance`, `FigmaManifestRecord`, and the Motion model (`MotionDoc`/`MotionTrack`/`TimelineSpec`/`GsapTween`) shared across the stack.
- **`parseFigmaRef`** — normalizes any user input (full `/design|/file|/proto` URLs with `?node-id=1-2`, `fileKey:nodeId` shorthand, bare `fileKey`) into `{ fileKey, nodeId }`, including the URL-dash → API-colon node-id conversion.
- **`freeze.ts`** — `freezeBytes`/`freezeUrl`/`freezeLocalFile` with a 256 MB cap; every Figma asset is frozen to a local file before it can reach a composition (determinism: no render-time network).
- **`manifest.ts`** — the `.media/manifest.jsonl` ledger (same layout `media-use` writes, so a project has one shared media inventory without either skill depending on the other): append/read/find-by-node/next-id, with a pure type-guard (`isFigmaManifestRecord`) instead of `as`-casts.
- **`assetSnippet.ts`** — manifest record → composition `<img>` snippet with escaped attrs + `data-figma-id`.
- **publishConfig fix** — `./figma` added to `packages/core` `publishConfig.exports` (the packed-manifest CI gate requires every source export to have a dist mapping).
## Why
Design spec: `docs/superpowers/specs/2026-06-30-figma-asset-integration-design.md`. These functions are deliberately transport-agnostic — when the project reversed from MCP-first to a REST/MCP split (spec §2), nothing in this layer changed. That was the point.
## Tests
Unit tests per module (URL variants, freeze cap edges, manifest round-trip/malformed-line tolerance, snippet escaping). All colocated `*.test.ts`, vitest, no network.
---
Stack (1/6): this PR → #1869 → #1870 → #1871 → #1872 → #1873
🤖 Generated with [Claude Code](https://claude.com/claude-code)
68 lines
2.6 KiB
TypeScript
68 lines
2.6 KiB
TypeScript
/**
|
|
* "Freeze" = write asset bytes to local disk permanently so renders never
|
|
* re-fetch from figma (design spec §5) — not Object.freeze.
|
|
*/
|
|
|
|
import { copyFileSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
// ponytail: bound the write so a hostile/runaway source can't fill the disk.
|
|
export const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
|
|
|
|
export function exceedsFreezeCap(byteLength: number): boolean {
|
|
return byteLength > MAX_FREEZE_BYTES;
|
|
}
|
|
|
|
export function freezeBytes(bytes: Uint8Array, destPath: string): number {
|
|
if (bytes.length === 0) throw new Error("freeze failed: empty bytes");
|
|
if (exceedsFreezeCap(bytes.length))
|
|
throw new Error(`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
|
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
// Exclusive create; on EEXIST remove and retry — never write through an
|
|
// existing file or planted symlink (CodeQL js/insecure-temporary-file).
|
|
try {
|
|
writeFileSync(destPath, bytes, { flag: "wx" });
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
rmSync(destPath);
|
|
writeFileSync(destPath, bytes, { flag: "wx" });
|
|
}
|
|
return bytes.length;
|
|
}
|
|
|
|
/**
|
|
* Only figma-owned hosts may be frozen from a URL — render/CDN responses
|
|
* come from figma.com subdomains or figma's S3 buckets. Blocks SSRF via a
|
|
* crafted manifest/config URL (metadata endpoints, internal services).
|
|
*/
|
|
export function isAllowedFreezeUrl(url: string): boolean {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (parsed.protocol !== "https:") return false;
|
|
const host = parsed.hostname;
|
|
return host === "figma.com" || host.endsWith(".figma.com") || host.endsWith(".amazonaws.com");
|
|
}
|
|
|
|
export async function freezeUrl(url: string, destPath: string): Promise<number> {
|
|
if (!isAllowedFreezeUrl(url))
|
|
throw new Error(`freeze failed: refusing non-figma url ${url} (https + figma hosts only)`);
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status}`);
|
|
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
if (exceedsFreezeCap(declared))
|
|
throw new Error(`freeze failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`);
|
|
return freezeBytes(new Uint8Array(await res.arrayBuffer()), destPath);
|
|
}
|
|
|
|
export function freezeLocalFile(srcPath: string, destPath: string): void {
|
|
const size = statSync(srcPath).size;
|
|
if (exceedsFreezeCap(size))
|
|
throw new Error(`freeze failed: ${size} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
|
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
copyFileSync(srcPath, destPath);
|
|
}
|