@animaapp/anima-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@animaapp/anima-sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Anima's JavaScript utilities library",
6
+ "author": "Anima App, Inc.",
7
+ "license": "ISC",
8
+ "packageManager": "yarn@4.6.0",
9
+ "exports": {
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.cjs"
12
+ },
13
+ "types": "./dist/index.d.ts",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/AnimaApp/anima-sdk.git"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org/"
21
+ },
22
+ "scripts": {
23
+ "build": "vite build",
24
+ "tests": "vitest run --dir ./tests",
25
+ "prepack": "yarn build"
26
+ },
27
+ "dependencies": {
28
+ "@animaapp/http-client-figma": "^1.0.2",
29
+ "@figma/rest-api-spec": "^0.23.0",
30
+ "zod": "^3.24.1"
31
+ },
32
+ "devDependencies": {
33
+ "vite": "^6.0.11",
34
+ "vite-plugin-dts": "^4.5.0",
35
+ "vite-tsconfig-paths": "^5.1.4",
36
+ "vitest": "^3.0.5"
37
+ }
38
+ }
package/src/anima.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { CodegenError } from "./errors";
2
+ import { validateSettings } from "./settings";
3
+ import {
4
+ AnimaFiles,
5
+ AnimaSDKResult,
6
+ GetCodeHandler,
7
+ GetCodeParams,
8
+ SSECodgenMessage,
9
+ } from "./types";
10
+ import { convertCodegenFilesToAnimaFiles } from "./codegenToAnimaFiles";
11
+ export type Auth =
12
+ | { token: string; teamId: string } // for Anima user, it's mandatory to have an associated team
13
+ | { token: string; userId?: string }; // for users from a 3rd-party client (e.g., Bolt) they may have optionally a user id
14
+
15
+ export class Anima {
16
+ #auth?: Auth;
17
+ #apiBaseAddress: string;
18
+
19
+ constructor({
20
+ auth,
21
+ apiBaseAddress = "https://public-api.animaapp.com",
22
+ }: {
23
+ auth?: Auth;
24
+ apiBaseAddress?: string;
25
+ path?: string;
26
+ } = {}) {
27
+ this.#apiBaseAddress = apiBaseAddress;
28
+
29
+ if (auth) {
30
+ this.auth = auth;
31
+ }
32
+ }
33
+
34
+ protected hasAuth() {
35
+ return !!this.#auth;
36
+ }
37
+
38
+ set auth(auth: Auth) {
39
+ this.#auth = auth;
40
+ }
41
+
42
+ protected get headers() {
43
+ const headers: Record<string, string> = {
44
+ "Content-Type": "application/json",
45
+ };
46
+
47
+ if (this.#auth) {
48
+ headers["Authorization"] = `Bearer ${this.#auth.token}`;
49
+
50
+ if ("teamId" in this.#auth) {
51
+ headers["X-Team-Id"] = this.#auth.teamId;
52
+ }
53
+
54
+ if ("userId" in this.#auth && this.#auth.userId) {
55
+ headers["X-User-Id"] = this.#auth.userId;
56
+ }
57
+ }
58
+
59
+ return headers;
60
+ }
61
+
62
+ async generateCode(params: GetCodeParams, handler: GetCodeHandler = {}) {
63
+ if (this.hasAuth() === false) {
64
+ throw new Error('It needs to set "auth" before calling this method.');
65
+ }
66
+
67
+ const result: Partial<AnimaSDKResult> = {};
68
+ const settings = validateSettings(params.settings);
69
+
70
+ const response = await fetch(`${this.#apiBaseAddress}/v1/codegen`, {
71
+ method: "POST",
72
+ headers: {
73
+ ...this.headers,
74
+ Accept: "text/event-stream",
75
+ },
76
+ body: JSON.stringify({
77
+ fileKey: params.fileKey,
78
+ figmaToken: params.figmaToken,
79
+ nodesId: params.nodesId,
80
+ language: settings.language,
81
+ framework: settings.framework,
82
+ styling: settings.styling,
83
+ uiLibrary: settings.uiLibrary,
84
+ enableTranslation: settings.enableTranslation,
85
+ }),
86
+ });
87
+
88
+ if (!response.ok) {
89
+ let errorMessage;
90
+ try {
91
+ const errorData = await response.json();
92
+ errorMessage =
93
+ errorData.message || `HTTP error! status: ${response.status}`;
94
+ } catch {
95
+ errorMessage = `HTTP error! status: ${response.status}`;
96
+ }
97
+ throw new CodegenError({
98
+ name: "HTTP Error",
99
+ reason: errorMessage,
100
+ status: response.status,
101
+ });
102
+ }
103
+
104
+ if (!response.body) {
105
+ throw new CodegenError({
106
+ name: "Stream Error",
107
+ reason: "Response body is null",
108
+ status: response.status,
109
+ });
110
+ }
111
+
112
+ const reader = response.body.getReader();
113
+ const decoder = new TextDecoder();
114
+ let buffer = "";
115
+
116
+ try {
117
+ while (true) {
118
+ const { done, value } = await reader.read();
119
+ if (done) {
120
+ break;
121
+ }
122
+
123
+ buffer += decoder.decode(value, { stream: true });
124
+
125
+ const lines = buffer.split("\n");
126
+
127
+ // Process all complete lines
128
+ buffer = lines.pop() || ""; // Keep the last incomplete line in the buffer
129
+
130
+ for (const line of lines) {
131
+ if (!line.trim() || line.startsWith(":")) continue;
132
+
133
+ if (line.startsWith("data: ")) {
134
+ let data: SSECodgenMessage;
135
+ try {
136
+ data = JSON.parse(line.slice(6));
137
+ } catch {
138
+ // ignore malformed JSON
139
+ continue;
140
+ }
141
+
142
+ switch (data.type) {
143
+ case "start": {
144
+ result.sessionId = data.sessionId;
145
+ typeof handler === "function"
146
+ ? handler(data)
147
+ : handler.onStart?.({ sessionId: data.sessionId });
148
+ break;
149
+ }
150
+
151
+ case "pre_codegen": {
152
+ typeof handler === "function"
153
+ ? handler(data)
154
+ : handler.onPreCodegen?.({ message: data.message });
155
+ break;
156
+ }
157
+
158
+ case "assets_uploaded": {
159
+ typeof handler === "function"
160
+ ? handler(data)
161
+ : handler.onAssetsUploaded?.();
162
+ break;
163
+ }
164
+
165
+ case "figma_metadata": {
166
+ result.figmaFileName = data.figmaFileName;
167
+ result.figmaSelectedFrameName = data.figmaSelectedFrameName;
168
+
169
+ typeof handler === "function"
170
+ ? handler(data)
171
+ : handler.onFigmaMetadata?.({
172
+ figmaFileName: data.figmaFileName,
173
+ figmaSelectedFrameName: data.figmaSelectedFrameName,
174
+ });
175
+ break;
176
+ }
177
+
178
+ case "generating_code": {
179
+ const codegenFiles = data.payload.files as Record<
180
+ string,
181
+ { code: string; type: "code" }
182
+ >;
183
+
184
+ const files = convertCodegenFilesToAnimaFiles(codegenFiles);
185
+
186
+ if (data.payload.status === "success") {
187
+ result.files = files;
188
+ }
189
+
190
+ typeof handler === "function"
191
+ ? handler(data)
192
+ : handler.onGeneratingCode?.({
193
+ status: data.payload.status,
194
+ progress: data.payload.progress,
195
+ files,
196
+ });
197
+ break;
198
+ }
199
+
200
+ case "codegen_completed": {
201
+ typeof handler === "function"
202
+ ? handler(data)
203
+ : handler.onCodegenCompleted?.();
204
+ break;
205
+ }
206
+
207
+ case "error": {
208
+ // not sure if we want to throw on "stream" errors
209
+ throw new CodegenError({
210
+ name: data.payload.errorName,
211
+ reason: data.payload.reason,
212
+ });
213
+ }
214
+
215
+ case "done": {
216
+ if (!result.files) {
217
+ // not sure if we want to throw on "logical" errors
218
+ // I think we should throw only on "HTTP" errors
219
+ throw new CodegenError({
220
+ name: "Invalid response",
221
+ reason: "No files found",
222
+ });
223
+ }
224
+ return result as AnimaSDKResult;
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ } finally {
231
+ reader.cancel();
232
+ }
233
+
234
+ throw new CodegenError({
235
+ name: "Connection",
236
+ reason: "Connection closed before the 'done' message",
237
+ status: 500,
238
+ });
239
+ }
240
+ }
@@ -0,0 +1,13 @@
1
+ import { AnimaFiles } from "./types";
2
+
3
+ export const convertCodegenFilesToAnimaFiles = (
4
+ codegenFiles: Record<string, { code: string; type: "code" }>
5
+ ): AnimaFiles => {
6
+ return Object.entries(codegenFiles).reduce(
7
+ (acc, [fileName, file]) => {
8
+ acc[fileName] = { content: file.code, isBinary: false };
9
+ return acc;
10
+ },
11
+ {} as AnimaFiles
12
+ );
13
+ };
@@ -0,0 +1,108 @@
1
+ import type { Anima } from "./anima";
2
+ import type { CodegenErrorReason } from "./errors";
3
+ import type { GetCodeParams, SSECodgenMessage } from "./types";
4
+
5
+ export type StreamCodgenMessage =
6
+ | Exclude<SSECodgenMessage, { type: "error" }>
7
+ | { type: "error"; payload: { message: CodegenErrorReason, status?: number } };
8
+
9
+ /**
10
+ * Start the code generation and creates a ReadableStream to output its result.
11
+ *
12
+ * The stream is closed when the codegen ends.
13
+ *
14
+ * @param {Anima} anima - An Anima service instance to generate the code from.
15
+ * @param {GetCodeParams} params - Parameters required for the code generation process.
16
+ * @returns {ReadableStream<StreamCodgenMessage>} - A ReadableStream that emits messages related to the code generation process.
17
+ */
18
+ export const createCodegenStream = (
19
+ anima: Anima,
20
+ params: GetCodeParams
21
+ ): ReadableStream<StreamCodgenMessage> => {
22
+ return new ReadableStream({
23
+ start(controller) {
24
+ anima
25
+ .generateCode(params, (message) => {
26
+ if (message.type === "error") {
27
+ console.log('NOT SURE IF THIS IS REACHABLE, ALL ERRORS ARE THROWING');
28
+ controller.enqueue({
29
+ type: "error",
30
+ payload: { message: message.payload.reason },
31
+ });
32
+ } else {
33
+ controller.enqueue(message);
34
+ }
35
+
36
+ if (message.type === "aborted" || message.type === "error") {
37
+ controller.close();
38
+ }
39
+ })
40
+ .then((result) => {
41
+ controller.enqueue({ type: "done" });
42
+ controller.close();
43
+ })
44
+ .catch((error) => {
45
+ controller.enqueue({
46
+ type: "error",
47
+ payload: {
48
+ message: "message" in error ? error.message : "Unknown",
49
+ status: "status" in error ? error.status : undefined,
50
+ },
51
+ });
52
+ controller.close();
53
+ });
54
+ },
55
+ });
56
+ };
57
+
58
+ /**
59
+ * Creates a Server-Sent Events (SSE) `Response` that forwards all messages from the code generation stream.
60
+ *
61
+ * But, if the first message indicates an error (e.g., connection failed), the function returns a 500 response with the error message.
62
+ *
63
+ * @param {Anima} anima - The Anima instance to use for creating the data stream.
64
+ * @param {GetCodeParams} params - The parameters for the code generation request.
65
+ * @returns {Promise<Response>} - A promise that resolves to an HTTP response.
66
+ */
67
+ export const createCodegenResponseEventStream = async (
68
+ anima: Anima,
69
+ params: GetCodeParams
70
+ ): Promise<Response> => {
71
+ const stream = createCodegenStream(anima, params);
72
+
73
+ const [verifyStream, consumerStream] = stream.tee();
74
+ const firstMessage = await verifyStream.getReader().read();
75
+
76
+ if (
77
+ firstMessage.done ||
78
+ !firstMessage.value ||
79
+ firstMessage.value?.type === "error" && firstMessage.value?.payload?.status
80
+ ) {
81
+ return new Response(JSON.stringify(firstMessage.value), {
82
+ status: firstMessage.value?.type === "error" ? (firstMessage.value?.payload?.status ?? 500) : 500,
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ },
86
+ });
87
+ }
88
+
89
+ const seeStream = consumerStream.pipeThrough(
90
+ new TransformStream({
91
+ transform(chunk, controller) {
92
+ const sseString = `event: ${chunk.type}\ndata: ${JSON.stringify(
93
+ chunk
94
+ )}\n\n`;
95
+ controller.enqueue(sseString);
96
+ },
97
+ })
98
+ );
99
+
100
+ return new Response(seeStream, {
101
+ status: 200,
102
+ headers: {
103
+ "Content-Type": "text/event-stream; charset=utf-8",
104
+ "Connection": "keep-alive",
105
+ "Cache-Control": "no-cache",
106
+ },
107
+ });
108
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ // TODO: `CodegenErrorReason` should be imported from `anima-public-api`
2
+ export type CodegenErrorReason =
3
+ | "The selected node is not a frame"
4
+ | "There is no node with the given id"
5
+ | "Invalid Figma token"
6
+ | "Figma token expired"
7
+ | "No files found"
8
+ | "Connection closed before the 'done' message"
9
+ | "Unknown"
10
+ | "Response body is null";
11
+
12
+ export class CodegenError extends Error {
13
+ status?: number;
14
+
15
+ constructor({ name, reason, status }: { name: string; reason: CodegenErrorReason; status?: number }) {
16
+ super();
17
+ this.name = `[Codegen Error] ${name}`;
18
+ this.message = reason;
19
+ this.status = status;
20
+ }
21
+ }
@@ -0,0 +1,115 @@
1
+ import type { CodegenErrorReason } from "../errors";
2
+
3
+ const figmaTokenIssueErrorMessage = "Figma Token Issue";
4
+ export class FigmaTokenIssue extends Error {
5
+ fileKey: string;
6
+ reason: string;
7
+
8
+ constructor({ fileKey, reason }: { fileKey: string; reason: string }) {
9
+ super(figmaTokenIssueErrorMessage);
10
+
11
+ this.fileKey = fileKey;
12
+ this.reason = reason;
13
+ }
14
+ }
15
+
16
+ const rateLimitExceededErrorMessage = "Rate Limit Exceeded";
17
+ export class RateLimitExceeded extends Error {
18
+ fileKey: string;
19
+
20
+ constructor({ fileKey }: { fileKey: string }) {
21
+ super(rateLimitExceededErrorMessage);
22
+
23
+ this.fileKey = fileKey;
24
+ }
25
+ }
26
+
27
+ // Not Found
28
+ const notFoundErrorMessage = "Not Found";
29
+ export class NotFound extends Error {
30
+ fileKey: string;
31
+
32
+ constructor({ fileKey }: { fileKey: string }) {
33
+ super(notFoundErrorMessage);
34
+
35
+ this.fileKey = fileKey;
36
+ }
37
+ }
38
+ export const isNotFound = (error: Error) => {
39
+ return error.message === notFoundErrorMessage;
40
+ };
41
+
42
+ // Unknown Exception
43
+ const unknownFigmaApiExceptionMessage = "Unknown Figma API Exception";
44
+ export class UnknownFigmaApiException extends Error {
45
+ fileKey: string;
46
+
47
+ constructor({ fileKey, cause }: { fileKey: string; cause: unknown }) {
48
+ super(unknownFigmaApiExceptionMessage);
49
+
50
+ this.name = "UnknownFigmaApiException";
51
+ this.fileKey = fileKey;
52
+ this.cause = cause;
53
+ }
54
+ }
55
+ export const isUnknownFigmaApiException = (error: Error) => {
56
+ return error.message === unknownFigmaApiExceptionMessage;
57
+ };
58
+
59
+ export const isRateLimitExceeded = (error: Error) => {
60
+ return error.message === rateLimitExceededErrorMessage;
61
+ };
62
+
63
+ export const isFigmaTokenIssue = (error: Error) => {
64
+ const figmaTokenCodegenErrors: CodegenErrorReason[] = [
65
+ "Invalid Figma token",
66
+ "Figma token expired",
67
+ ];
68
+
69
+ return [figmaTokenIssueErrorMessage, ...figmaTokenCodegenErrors].includes(
70
+ error.message
71
+ );
72
+ };
73
+
74
+ export const handleFigmaApiError = (error: any, fileKey: string) => {
75
+ const err = error?.cause?.body || error.body;
76
+
77
+ if (err?.status === 403) {
78
+ throw new FigmaTokenIssue({
79
+ fileKey,
80
+ reason: error?.cause?.body || error.body,
81
+ });
82
+ }
83
+
84
+ if (err?.status === 429) {
85
+ throw new RateLimitExceeded({ fileKey });
86
+ }
87
+
88
+ if (err?.status === 404) {
89
+ throw new NotFound({ fileKey });
90
+ }
91
+
92
+ throw new UnknownFigmaApiException({ fileKey, cause: error });
93
+ };
94
+
95
+ export type FigmaApiErrorType =
96
+ | "FigmaTokenIssue"
97
+ | "RateLimitExceeded"
98
+ | "NotFound"
99
+ | "UnknownFigmaApiException";
100
+
101
+ export const getFigmaApiErrorType = (error: Error): FigmaApiErrorType => {
102
+ if (isNotFound(error)) {
103
+ return "NotFound";
104
+ }
105
+
106
+ if (isRateLimitExceeded(error)) {
107
+ return "RateLimitExceeded";
108
+ }
109
+
110
+ if (isFigmaTokenIssue(error)) {
111
+ return "FigmaTokenIssue";
112
+ }
113
+
114
+ return "UnknownFigmaApiException";
115
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./figmaError";
2
+ export * from "./utils";
@@ -0,0 +1,73 @@
1
+ import type { GetFileResponse, Node } from '@figma/rest-api-spec';
2
+ import { FigmaRestApi } from '@animaapp/http-client-figma';
3
+ import { handleFigmaApiError } from './figmaError';
4
+
5
+ export type FigmaNode = Node;
6
+ export type GetFileParams = { fileKey: string; authToken?: string; figmaRestApi?: FigmaRestApi };
7
+
8
+ export type FigmaPage = { id: string; name: string };
9
+ export type GetFilePagesParams = {
10
+ fileKey: string;
11
+ authToken?: string;
12
+ figmaRestApi?: FigmaRestApi;
13
+ params?: Record<string, any>;
14
+ };
15
+ export type GetFilePagesResult = FigmaPage[] | undefined;
16
+ export type GetFileNodesParams = {
17
+ fileKey: string;
18
+ authToken?: string;
19
+ nodeIds: string[];
20
+ figmaRestApi?: FigmaRestApi;
21
+ params?: Record<string, any>;
22
+ };
23
+
24
+ export type GetFigmaFileResult = GetFileResponse | undefined;
25
+
26
+ export const getFigmaFile = async ({
27
+ fileKey,
28
+ authToken,
29
+ figmaRestApi = new FigmaRestApi(),
30
+ params = {},
31
+ }: GetFilePagesParams): Promise<GetFigmaFileResult> => {
32
+ if (authToken) {
33
+ figmaRestApi.token = authToken;
34
+ }
35
+
36
+ try {
37
+ const rootFile = await figmaRestApi.files.get({
38
+ fileKey,
39
+ params,
40
+ });
41
+
42
+ return rootFile;
43
+ } catch (error) {
44
+ console.error(error);
45
+ throw error;
46
+ }
47
+ };
48
+
49
+ export const getFileNodes = async ({
50
+ fileKey,
51
+ authToken,
52
+ nodeIds,
53
+ figmaRestApi = new FigmaRestApi(),
54
+ params = {},
55
+ }: GetFileNodesParams) => {
56
+ if (authToken) {
57
+ figmaRestApi.token = authToken;
58
+ }
59
+
60
+ try {
61
+ const data = await figmaRestApi.nodes.get({
62
+ fileKey,
63
+ nodeIds,
64
+ params: {
65
+ ...params,
66
+ },
67
+ });
68
+
69
+ return data.nodes;
70
+ } catch (error) {
71
+ return handleFigmaApiError(error, fileKey);
72
+ }
73
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./anima";
2
+ export * from "./types";
3
+ export * from "./errors";
4
+ export * from "./utils";
5
+ export * from "./figma";
6
+ export * from "./settings";
7
+ export * from "./dataStream";
8
+ export * from "./codegenToAnimaFiles";
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+
3
+ const CodegenSettingsSchema = z
4
+ .object({
5
+ language: z.enum(["typescript", "javascript"]).optional(),
6
+ })
7
+ .and(
8
+ z.union([
9
+ z.object({
10
+ framework: z.literal("react"),
11
+ styling: z.enum([
12
+ "plain_css",
13
+ "css_modules",
14
+ "styled_components",
15
+ "tailwind",
16
+ "sass",
17
+ "scss",
18
+ "inline_styles",
19
+ ]),
20
+ uiLibrary: z.enum(["mui", "antd", "radix", "shadcn"]).optional(),
21
+ }),
22
+ z.object({
23
+ framework: z.literal("html"),
24
+ styling: z.enum(["plain_css", "inline_styles"]),
25
+ enableTranslation: z.boolean().optional(),
26
+ }),
27
+ ])
28
+ );
29
+
30
+ // We don't use the z.infer method here because the types returned by zod aren't ergonic
31
+ export type CodegenSettings = {
32
+ language?: "typescript" | "javascript";
33
+ framework: "react" | "html";
34
+ styling:
35
+ | "plain_css"
36
+ | "css_modules"
37
+ | "styled_components"
38
+ | "tailwind"
39
+ | "sass"
40
+ | "scss"
41
+ | "inline_styles"
42
+ uiLibrary?: "mui" | "antd" | "radix" | "shadcn";
43
+ enableTranslation?: boolean;
44
+ };
45
+
46
+ export const validateSettings = (obj: unknown): CodegenSettings => {
47
+ const parsedObj = CodegenSettingsSchema.safeParse(obj);
48
+
49
+ if (parsedObj.success === false) {
50
+ const error = new Error("Invalid codegen settings");
51
+ error.cause = parsedObj.error;
52
+ throw error;
53
+ }
54
+
55
+ return parsedObj.data;
56
+ };