Files
hyperframes/packages/core/src/figma/freeze.ts
T
Vance Ingalls 397c3ba7a8 feat(core): figma module foundations — types, parseFigmaRef, freeze, manifest, asset snippet (#1868)
## 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)
2026-07-03 14:12:24 -07:00

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);
}