Skip to content
Merged
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
20 changes: 19 additions & 1 deletion packages/cli/src/commands/figma/asset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
runAssetImportMany,
type AssetImportDeps,
} from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";
import { FigmaClientError, type FigmaClient } from "@hyperframes/core/figma";

const dirs: string[] = [];
function scratch(): string {
Expand Down Expand Up @@ -179,6 +179,24 @@ describe("runAssetImport", () => {
expect(new Set(results.map((r) => r.record.id)).size).toBe(3);
});

it("labels a batch-miss RENDER_FAILED with the images endpoint (telemetry parity with client.ts)", async () => {
const dir = scratch();
const missClient = fakeClient({
renderNodes: (fileKey, nodeIds) =>
Promise.resolve(nodeIds.map((nodeId) => ({ nodeId, url: null, ext: "png" }))),
});
const err = await runAssetImportMany(
["KEY:1-2"],
{ format: "png" },
deps(dir, { client: missClient }),
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.code).toBe("RENDER_FAILED");
expect(err.endpoint).toBe("images");
}
});

it("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => {
// bare tokens comma-split
expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/figma/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ export async function runAssetImportMany(
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
undefined,
"images",
);
slots[i] = await freezeAndRecord(
fileKey,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/figma/cliError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise<void>):
stack_trace: err.stack,
command,
kind: "command_error",
endpoint: err instanceof FigmaClientError ? err.endpoint : undefined,
});
await telemetry.flush();
} catch {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/telemetry/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,21 @@ describe("trackCliError", () => {
expect(props.error_message).toContain("[path]");
expect(props.stack_trace).not.toContain("/Users/alice");
});

it("forwards the figma endpoint label when supplied", () => {
trackCliError({
error_name: "RATE_LIMITED",
error_message: "figma rate limit hit (429)",
command: "figma asset",
kind: "command_error",
endpoint: "images",
});

expect(trackEvent).toHaveBeenCalledWith(
"cli_error",
expect.objectContaining({ endpoint: "images" }),
);
});
});

describe("trackCommandFailure", () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,9 @@ export function trackCliError(props: {
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
/** Low-cardinality figma REST call label (e.g. "images", "files_nodes") —
* which endpoint failed, for FigmaClientError-backed failures only. */
endpoint?: string;
}): void {
trackEvent("cli_error", {
error_name: props.error_name,
Expand All @@ -502,6 +505,7 @@ export function trackCliError(props: {
: undefined,
command: props.command,
kind: props.kind,
endpoint: props.endpoint,
});
}

Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/figma/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,54 @@ describe("error mapping", () => {
});
});

describe("endpoint attribution", () => {
it("labels each call's error with a low-cardinality endpoint, never the raw fileKey/nodeId", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(500, {})).fetch,
});
const cases: Array<[string, () => Promise<unknown>]> = [
["images", () => client.renderNode({ fileKey: "SECRET", nodeId: "1:2" }, { format: "png" })],
["images", () => client.renderNodes("SECRET", ["1:2"], { format: "png" })],
["files_images", () => client.imageFills("SECRET")],
["variables_local", () => client.variables("SECRET")],
["styles", () => client.styles("SECRET")],
["files_nodes", () => client.nodeTree({ fileKey: "SECRET", nodeId: "1:2" })],
["file_meta", () => client.fileVersion("SECRET")],
];
for (const [expected, call] of cases) {
const err = await call().catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.endpoint).toBe(expected);
expect(err.endpoint).not.toContain("SECRET");
}
}
});

it("still labels RENDER_FAILED and NODE_NOT_FOUND (thrown outside the shared get())", async () => {
const nullRender = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(200, { images: { "1:2": null } })).fetch,
});
const renderErr = await nullRender
.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" })
.catch((e: unknown) => e);
expect(renderErr).toBeInstanceOf(FigmaClientError);
if (renderErr instanceof FigmaClientError) expect(renderErr.endpoint).toBe("images");

const missingNode = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(200, { nodes: {} })).fetch,
});
const notFoundErr = await missingNode
.nodeTree({ fileKey: "F", nodeId: "9:9" })
.catch((e: unknown) => e);
expect(notFoundErr).toBeInstanceOf(FigmaClientError);
if (notFoundErr instanceof FigmaClientError) expect(notFoundErr.endpoint).toBe("files_nodes");
});
});

describe("renderNodes (batch)", () => {
it("fetches many nodes in ONE /v1/images call and maps each url", async () => {
const stub = fetchStub(() =>
Expand Down
40 changes: 35 additions & 5 deletions packages/core/src/figma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ export type FigmaClientErrorCode =
export class FigmaClientError extends Error {
readonly code: FigmaClientErrorCode;
readonly status?: number;
/** Low-cardinality REST call label (e.g. "images", "files_nodes") for
* telemetry attribution — never the raw fileKey/nodeId path. */
readonly endpoint?: string;

constructor(code: FigmaClientErrorCode, message: string, status?: number) {
constructor(code: FigmaClientErrorCode, message: string, status?: number, endpoint?: string) {
super(message);
this.name = "FigmaClientError";
this.code = code;
this.status = status;
this.endpoint = endpoint;
}
}

Expand Down Expand Up @@ -217,6 +221,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
enterpriseGated?: boolean;
/** scope named in a FORBIDDEN message so the user knows which to add. */
scopeHint?: string;
/** low-cardinality call label carried onto any thrown FigmaClientError. */
endpoint: string;
}

/** Map a 403 to the right typed error using figma's own response body:
Expand All @@ -233,18 +239,21 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"BAD_TOKEN",
"figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
403,
opts.endpoint,
);
if (opts.enterpriseGated)
return new FigmaClientError(
"REQUIRES_ENTERPRISE",
"figma variables require an Enterprise plan (403) — fall back to styles",
403,
opts.endpoint,
);
if (body && /scope/i.test(body))
return new FigmaClientError(
"FORBIDDEN",
`figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`,
403,
opts.endpoint,
);
const scopeLine = opts.scopeHint
? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.`
Expand All @@ -253,6 +262,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"FORBIDDEN",
`figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`,
403,
opts.endpoint,
);
}

Expand All @@ -264,22 +274,25 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"BAD_TOKEN",
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
401,
opts.endpoint,
);
if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts);
if (res.status === 429)
throw new FigmaClientError(
"RATE_LIMITED",
`figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`,
429,
opts.endpoint,
);
throw new FigmaClientError(
"HTTP_ERROR",
`figma request failed: HTTP ${res.status} ${path}`,
res.status,
opts.endpoint,
);
}

async function get(path: string, opts: GetOptions = {}): Promise<unknown> {
async function get(path: string, opts: GetOptions): Promise<unknown> {
// Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is
// per-minute, so a couple of imports in quick succession hit it and a
// short wait clears it. Honor Retry-After when present, else exponential.
Expand All @@ -302,6 +315,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
undefined,
"images",
);
return { url: result.url, ext: opts.format };
},
Expand All @@ -314,6 +329,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
const body = await get(`/v1/images/${fileKey}?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "images",
});
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
return nodeIds.map((nodeId) => {
Expand All @@ -327,7 +343,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},

async imageFills(fileKey) {
const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent });
const body = await get(`/v1/files/${fileKey}/images`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "files_images",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const images = isRecord(meta.images) ? meta.images : {};
const out = new Map<string, string>();
Expand All @@ -338,7 +357,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},

async variables(fileKey) {
const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true });
const body = await get(`/v1/files/${fileKey}/variables/local`, {
enterpriseGated: true,
endpoint: "variables_local",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const variables = isRecord(meta.variables) ? meta.variables : {};
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
Expand All @@ -353,6 +375,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
async styles(fileKey) {
const body = await get(`/v1/files/${fileKey}/styles`, {
scopeHint: SCOPE_HINTS.libraryContent,
endpoint: "styles",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const styles = Array.isArray(meta.styles) ? meta.styles : [];
Expand All @@ -370,6 +393,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "files_nodes",
});
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
const entry = nodes[nodeId];
Expand All @@ -380,13 +404,19 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
typeof doc.name !== "string" ||
typeof doc.type !== "string"
)
throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`);
throw new FigmaClientError(
"NODE_NOT_FOUND",
`node ${nodeId} not found in ${ref.fileKey}`,
undefined,
"files_nodes",
);
return { ...doc, id: doc.id, name: doc.name, type: doc.type };
},

async fileVersion(fileKey) {
const body = await get(`/v1/files/${fileKey}?depth=1`, {
scopeHint: SCOPE_HINTS.fileMetadata,
endpoint: "file_meta",
});
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
const lastModified =
Expand Down
Loading