From 4389ab24147aee69bc1f9e5ac05d4b5a6745fbbe Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:22:17 +0600 Subject: [PATCH 01/15] test(utils): add comprehensive tests for utility functions --- packages/frames.js/src/utils.test.ts | 212 +++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 packages/frames.js/src/utils.test.ts diff --git a/packages/frames.js/src/utils.test.ts b/packages/frames.js/src/utils.test.ts new file mode 100644 index 00000000..7da8d608 --- /dev/null +++ b/packages/frames.js/src/utils.test.ts @@ -0,0 +1,212 @@ +import { + isFrameButtonLink, + isFrameButtonTx, + isFrameButtonMint, + bytesToHexString, + getByteLength, + hexStringToUint8Array, + normalizeCastId, + isValidVersion, + escapeHtmlAttributeValue, +} from "./utils"; + +describe("escapeHtmlAttributeValue", () => { + it("escapes double quotes", () => { + expect(escapeHtmlAttributeValue('test"value')).toBe("test"value"); + }); + + it("escapes single quotes", () => { + expect(escapeHtmlAttributeValue("test'value")).toBe("test'value"); + }); + + it("escapes less than sign", () => { + expect(escapeHtmlAttributeValue("test { + expect(escapeHtmlAttributeValue("test>value")).toBe("test>value"); + }); + + it("escapes multiple special characters", () => { + expect(escapeHtmlAttributeValue('')).toBe( + "<script>"alert('xss')"</script>" + ); + }); + + it("returns empty string unchanged", () => { + expect(escapeHtmlAttributeValue("")).toBe(""); + }); + + it("returns string without special characters unchanged", () => { + expect(escapeHtmlAttributeValue("normal text")).toBe("normal text"); + }); + + it("handles string with only special characters", () => { + expect(escapeHtmlAttributeValue("\"'<>")).toBe(""'<>"); + }); +}); + +describe("isValidVersion", () => { + it("returns true for vNext", () => { + expect(isValidVersion("vNext")).toBe(true); + }); + + it("returns true for valid date format YYYY-MM-DD", () => { + expect(isValidVersion("2024-01-15")).toBe(true); + expect(isValidVersion("2023-12-31")).toBe(true); + expect(isValidVersion("2025-06-01")).toBe(true); + }); + + it("returns false for invalid date format", () => { + expect(isValidVersion("24-01-15")).toBe(false); + expect(isValidVersion("2024/01/15")).toBe(false); + expect(isValidVersion("2024.01.15")).toBe(false); + expect(isValidVersion("01-15-2024")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isValidVersion("")).toBe(false); + }); + + it("returns false for random strings", () => { + expect(isValidVersion("v1.0.0")).toBe(false); + expect(isValidVersion("version1")).toBe(false); + expect(isValidVersion("latest")).toBe(false); + }); + + it("returns false for partial date formats", () => { + expect(isValidVersion("2024-01")).toBe(false); + expect(isValidVersion("2024")).toBe(false); + }); +}); + +describe("isFrameButtonLink", () => { + it("returns true for link action button", () => { + const button = { action: "link" as const, target: "https://example.com", label: "Click" }; + expect(isFrameButtonLink(button)).toBe(true); + }); + + it("returns false for post action button", () => { + const button = { action: "post" as const, label: "Submit" }; + expect(isFrameButtonLink(button)).toBe(false); + }); + + it("returns false for tx action button", () => { + const button = { action: "tx" as const, target: "https://example.com", label: "Send" }; + expect(isFrameButtonLink(button)).toBe(false); + }); + + it("returns false for mint action button", () => { + const button = { action: "mint" as const, target: "eip155:1:0x123", label: "Mint" }; + expect(isFrameButtonLink(button)).toBe(false); + }); +}); + +describe("isFrameButtonTx", () => { + it("returns true for tx action button", () => { + const button = { action: "tx" as const, target: "https://example.com", label: "Send" }; + expect(isFrameButtonTx(button)).toBe(true); + }); + + it("returns false for link action button", () => { + const button = { action: "link" as const, target: "https://example.com", label: "Click" }; + expect(isFrameButtonTx(button)).toBe(false); + }); + + it("returns false for post action button", () => { + const button = { action: "post" as const, label: "Submit" }; + expect(isFrameButtonTx(button)).toBe(false); + }); +}); + +describe("isFrameButtonMint", () => { + it("returns true for mint action button", () => { + const button = { action: "mint" as const, target: "eip155:1:0x123", label: "Mint" }; + expect(isFrameButtonMint(button)).toBe(true); + }); + + it("returns false for link action button", () => { + const button = { action: "link" as const, target: "https://example.com", label: "Click" }; + expect(isFrameButtonMint(button)).toBe(false); + }); + + it("returns false for tx action button", () => { + const button = { action: "tx" as const, target: "https://example.com", label: "Send" }; + expect(isFrameButtonMint(button)).toBe(false); + }); +}); + +describe("bytesToHexString", () => { + it("converts empty Uint8Array to 0x", () => { + expect(bytesToHexString(new Uint8Array([]))).toBe("0x"); + }); + + it("converts single byte to hex", () => { + expect(bytesToHexString(new Uint8Array([255]))).toBe("0xff"); + expect(bytesToHexString(new Uint8Array([0]))).toBe("0x00"); + expect(bytesToHexString(new Uint8Array([16]))).toBe("0x10"); + }); + + it("converts multiple bytes to hex", () => { + expect(bytesToHexString(new Uint8Array([1, 2, 3]))).toBe("0x010203"); + expect(bytesToHexString(new Uint8Array([255, 0, 128]))).toBe("0xff0080"); + }); +}); + +describe("getByteLength", () => { + it("returns 0 for empty string", () => { + expect(getByteLength("")).toBe(0); + }); + + it("returns correct length for ASCII string", () => { + expect(getByteLength("hello")).toBe(5); + expect(getByteLength("test")).toBe(4); + }); + + it("returns correct length for UTF-8 multibyte characters", () => { + // Emoji takes 4 bytes + expect(getByteLength("😀")).toBe(4); + // Japanese character takes 3 bytes + expect(getByteLength("日")).toBe(3); + }); +}); + +describe("hexStringToUint8Array", () => { + it("converts hex string to Uint8Array", () => { + const result = hexStringToUint8Array("010203"); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("converts hex string with 0x prefix", () => { + // Note: This removes the 0x prefix handling - just testing raw hex + const result = hexStringToUint8Array("ff0080"); + expect(result).toEqual(new Uint8Array([255, 0, 128])); + }); + + it("throws error for empty string", () => { + expect(() => hexStringToUint8Array("")).toThrow("Invalid hex string provided"); + }); +}); + +describe("normalizeCastId", () => { + it("normalizes cast id with hash as Uint8Array", () => { + const castId = { + fid: 123, + hash: new Uint8Array([1, 2, 3, 4]), + }; + const result = normalizeCastId(castId); + expect(result).toEqual({ + fid: 123, + hash: "0x01020304", + }); + }); + + it("preserves fid value", () => { + const castId = { + fid: 999999, + hash: new Uint8Array([255]), + }; + const result = normalizeCastId(castId); + expect(result.fid).toBe(999999); + }); +}); From 03a903fa43973aefba5f5a74cc7a980ffe7752c9 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:24:05 +0600 Subject: [PATCH 02/15] test(core): add tests for joinPaths, parseSearchParams, and type guards --- packages/frames.js/src/core/utils.test.ts | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/frames.js/src/core/utils.test.ts b/packages/frames.js/src/core/utils.test.ts index 057dd4fc..36f6d83f 100644 --- a/packages/frames.js/src/core/utils.test.ts +++ b/packages/frames.js/src/core/utils.test.ts @@ -3,6 +3,10 @@ import { parseButtonInformationFromTargetURL, resolveBaseUrl, generateTargetURL, + joinPaths, + parseSearchParams, + isFrameRedirect, + isFrameDefinition, } from "./utils"; describe("generateTargetURL", () => { @@ -242,3 +246,106 @@ describe("parseButtonInformationFromTargetURL", () => { }); }); }); + +describe("joinPaths", () => { + it("returns pathA when pathB is empty string", () => { + expect(joinPaths("/base", "")).toBe("/base"); + }); + + it("returns pathA when pathB is /", () => { + expect(joinPaths("/base", "/")).toBe("/base"); + }); + + it("joins two paths correctly", () => { + expect(joinPaths("/base", "/path")).toBe("/base/path"); + }); + + it("removes duplicate slashes", () => { + expect(joinPaths("/base/", "/path")).toBe("/base/path"); + expect(joinPaths("/base//", "//path")).toBe("/base/path"); + }); + + it("handles paths without leading slash", () => { + expect(joinPaths("base", "path")).toBe("base/path"); + }); +}); + +describe("parseSearchParams", () => { + it("returns empty object for URL without search params", () => { + const url = new URL("http://test.com"); + expect(parseSearchParams(url)).toEqual({ searchParams: {} }); + }); + + it("parses single search param", () => { + const url = new URL("http://test.com?key=value"); + expect(parseSearchParams(url)).toEqual({ searchParams: { key: "value" } }); + }); + + it("parses multiple search params", () => { + const url = new URL("http://test.com?a=1&b=2&c=3"); + expect(parseSearchParams(url)).toEqual({ + searchParams: { a: "1", b: "2", c: "3" }, + }); + }); +}); + +describe("isFrameRedirect", () => { + it("returns true for valid redirect object", () => { + expect(isFrameRedirect({ kind: "redirect", location: "https://example.com" })).toBe(true); + }); + + it("returns false for null", () => { + expect(isFrameRedirect(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isFrameRedirect(undefined)).toBe(false); + }); + + it("returns false for object without kind property", () => { + expect(isFrameRedirect({ location: "https://example.com" })).toBe(false); + }); + + it("returns false for object with wrong kind value", () => { + expect(isFrameRedirect({ kind: "other", location: "https://example.com" })).toBe(false); + }); + + it("returns false for primitive values", () => { + expect(isFrameRedirect("redirect")).toBe(false); + expect(isFrameRedirect(123)).toBe(false); + expect(isFrameRedirect(true)).toBe(false); + }); +}); + +describe("isFrameDefinition", () => { + it("returns true for object with image property", () => { + expect(isFrameDefinition({ image: "https://example.com/image.png" })).toBe(true); + }); + + it("returns true for complete frame definition", () => { + expect( + isFrameDefinition({ + image: "https://example.com/image.png", + buttons: [], + state: { count: 0 }, + }) + ).toBe(true); + }); + + it("returns false for null", () => { + expect(isFrameDefinition(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isFrameDefinition(undefined)).toBe(false); + }); + + it("returns false for object without image property", () => { + expect(isFrameDefinition({ buttons: [] })).toBe(false); + }); + + it("returns false for primitive values", () => { + expect(isFrameDefinition("image")).toBe(false); + expect(isFrameDefinition(123)).toBe(false); + }); +}); From 97153cdca190f5f3c461d903469feebaa7231688 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:25:04 +0600 Subject: [PATCH 03/15] test(core): add comprehensive tests for error classes --- packages/frames.js/src/core/errors.test.ts | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/frames.js/src/core/errors.test.ts diff --git a/packages/frames.js/src/core/errors.test.ts b/packages/frames.js/src/core/errors.test.ts new file mode 100644 index 00000000..52e7ecc7 --- /dev/null +++ b/packages/frames.js/src/core/errors.test.ts @@ -0,0 +1,86 @@ +import { + RequestBodyNotJSONError, + InvalidFrameActionPayloadError, + FrameMessageError, +} from "./errors"; + +describe("RequestBodyNotJSONError", () => { + it("has correct error message", () => { + const error = new RequestBodyNotJSONError(); + expect(error.message).toBe( + "Invalid frame action payload, request body is not JSON" + ); + }); + + it("is instance of Error", () => { + const error = new RequestBodyNotJSONError(); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe("InvalidFrameActionPayloadError", () => { + it("has default error message", () => { + const error = new InvalidFrameActionPayloadError(); + expect(error.message).toBe("Invalid frame action payload"); + }); + + it("accepts custom error message", () => { + const error = new InvalidFrameActionPayloadError("Custom error message"); + expect(error.message).toBe("Custom error message"); + }); + + it("is instance of Error", () => { + const error = new InvalidFrameActionPayloadError(); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe("FrameMessageError", () => { + it("creates error with valid message and status", () => { + const error = new FrameMessageError("Test error message", 400); + expect(error.message).toBe("Test error message"); + expect(error.status).toBe(400); + }); + + it("is instance of Error", () => { + const error = new FrameMessageError("Test", 400); + expect(error).toBeInstanceOf(Error); + }); + + it("throws error for message longer than 90 characters", () => { + const longMessage = "a".repeat(91); + expect(() => new FrameMessageError(longMessage, 400)).toThrow( + "Message too long" + ); + }); + + it("allows message with exactly 90 characters", () => { + const maxMessage = "a".repeat(90); + const error = new FrameMessageError(maxMessage, 400); + expect(error.message).toBe(maxMessage); + }); + + it("throws error for status code less than 400", () => { + expect(() => new FrameMessageError("Test", 399)).toThrow( + "Invalid status code" + ); + expect(() => new FrameMessageError("Test", 200)).toThrow( + "Invalid status code" + ); + }); + + it("throws error for status code 500 or greater", () => { + expect(() => new FrameMessageError("Test", 500)).toThrow( + "Invalid status code" + ); + expect(() => new FrameMessageError("Test", 501)).toThrow( + "Invalid status code" + ); + }); + + it("allows all valid 4XX status codes", () => { + expect(() => new FrameMessageError("Test", 400)).not.toThrow(); + expect(() => new FrameMessageError("Test", 404)).not.toThrow(); + expect(() => new FrameMessageError("Test", 499)).not.toThrow(); + }); +}); From 2ba3ca08c7c01ceae2b76ab6b790a17d833e3d18 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:26:17 +0600 Subject: [PATCH 04/15] docs(utils): add JSDoc documentation to utility functions --- packages/frames.js/src/utils.ts | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/frames.js/src/utils.ts b/packages/frames.js/src/utils.ts index c05c7af1..842fc4df 100644 --- a/packages/frames.js/src/utils.ts +++ b/packages/frames.js/src/utils.ts @@ -8,32 +8,63 @@ import type { FrameButtonTx, } from "./types"; +/** + * Type guard to check if a frame button is a link button. + * @param frameButton - The frame button to check + * @returns True if the button action is "link" + */ export function isFrameButtonLink( frameButton: FrameButton ): frameButton is FrameButtonLink { return frameButton.action === "link"; } +/** + * Type guard to check if a frame button is a transaction button. + * @param frameButton - The frame button to check + * @returns True if the button action is "tx" + */ export function isFrameButtonTx( frameButton: FrameButton ): frameButton is FrameButtonTx { return frameButton.action === "tx"; } +/** + * Type guard to check if a frame button is a mint button. + * @param frameButton - The frame button to check + * @returns True if the button action is "mint" + */ export function isFrameButtonMint( frameButton: FrameButton ): frameButton is FrameButtonMint { return frameButton.action === "mint"; } +/** + * Converts a Uint8Array of bytes to a hex string prefixed with 0x. + * @param bytes - The bytes to convert + * @returns Hex string representation with 0x prefix + */ export function bytesToHexString(bytes: Uint8Array): `0x${string}` { return `0x${Buffer.from(bytes).toString("hex")}`; } +/** + * Gets the byte length of a string in UTF-8 encoding. + * @param str - The string to measure + * @returns The byte length of the string + */ export function getByteLength(str: string): number { return Buffer.from(str).byteLength; } +/** + * Converts a hex string to a Uint8Array. + * @param hexstring - The hex string to convert (without 0x prefix) + * @returns Uint8Array representation of the hex string + * @throws Error if the hex string is invalid + */ export function hexStringToUint8Array(hexstring: string): Uint8Array { const matches = hexstring.match(/.{1,2}/g); @@ -44,6 +75,11 @@ export function hexStringToUint8Array(hexstring: string): Uint8Array { return new Uint8Array(matches.map((byte: string) => parseInt(byte, 16))); } +/** + * Normalizes a CastId by converting the hash from Uint8Array to hex string. + * @param castId - The cast ID to normalize + * @returns Normalized cast ID with hash as hex string + */ export function normalizeCastId(castId: CastId): { fid: number; hash: `0x${string}`; @@ -92,6 +128,12 @@ export function isValidVersion(version: string): boolean { return true; } +/** + * Gets the key name of an enum by its value. + * @param enumDefinition - The enum definition object + * @param enumValue - The enum value to look up + * @returns The key name corresponding to the value, or empty string if not found + */ export function getEnumKeyByEnumValue< TEnumKey extends string, TEnumVal extends string | number, @@ -101,11 +143,17 @@ export function getEnumKeyByEnumValue< ): string { return ( Object.keys(enumDefinition)[ - Object.values(enumDefinition).indexOf(enumValue) + Object.values(enumDefinition).indexOf(enumValue) ] ?? "" ); } +/** + * Extracts the Ethereum address from a Farcaster verification message. + * @param message - The JSON message containing verification data + * @returns The extracted Ethereum address or null if not Ethereum protocol + * @throws Error if message data is invalid or missing required fields + */ export function extractAddressFromJSONMessage( message: unknown ): `0x${string}` | null { @@ -117,8 +165,7 @@ export function extractAddressFromJSONMessage( if (data.type !== MessageType.VERIFICATION_ADD_ETH_ADDRESS) { throw new Error( - `Invalid message provided. Expected message type to be ${ - MessageType.VERIFICATION_ADD_ETH_ADDRESS + `Invalid message provided. Expected message type to be ${MessageType.VERIFICATION_ADD_ETH_ADDRESS } but got ${getEnumKeyByEnumValue(MessageType, data.type)}.` ); } From da7b1feb556603106eb8ed40bb0246e5ea7fe323 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:27:26 +0600 Subject: [PATCH 05/15] docs(core): add JSDoc documentation to core utility functions --- packages/frames.js/src/core/utils.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/frames.js/src/core/utils.ts b/packages/frames.js/src/core/utils.ts index d06a0b93..f375530c 100644 --- a/packages/frames.js/src/core/utils.ts +++ b/packages/frames.js/src/core/utils.ts @@ -12,12 +12,25 @@ type ButtonActions = keyof typeof buttonActionToCode; const BUTTON_INFORMATION_SEARCH_PARAM_NAME = "__bi"; +/** + * Joins two path segments, handling empty paths and removing duplicate slashes. + * @param pathA - The first path segment + * @param pathB - The second path segment to append + * @returns The joined path with duplicate slashes removed + */ export function joinPaths(pathA: string, pathB: string): string { return pathB === "/" || pathB === "" ? pathA : [pathA, pathB].join("/").replace(/\/{2,}/g, "/"); } +/** + * Resolves the base URL for frame requests. + * @param request - The incoming request + * @param baseUrl - Optional explicit base URL + * @param basePath - The base path to append + * @returns The resolved URL + */ export function resolveBaseUrl( request: Request, baseUrl: URL | undefined, @@ -34,6 +47,11 @@ export function resolveBaseUrl( return new URL(basePath, request.url); } +/** + * Validates if a value is a valid button index (1-4). + * @param index - The value to check + * @returns True if the value is a valid button index + */ function isValidButtonIndex(index: unknown): index is 1 | 2 | 3 | 4 { return ( typeof index === "number" && @@ -43,10 +61,20 @@ function isValidButtonIndex(index: unknown): index is 1 | 2 | 3 | 4 { ); } +/** + * Validates if a value is a valid button action type. + * @param action - The value to check + * @returns True if the value is a valid button action + */ function isValidButtonAction(action: unknown): action is ButtonActions { return typeof action === "string" && action in buttonActionToCode; } +/** + * Checks if a URL object has all required properties to construct a complete URL. + * @param urlObject - The URL object to check + * @returns True if the object has host, protocol, and pathname + */ function isUrlObjectComplete(urlObject: UrlObject): boolean { return ( !!urlObject.host && From dbd8b3ffc09414f78a06066f9591b1f6cc7ded92 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:28:11 +0600 Subject: [PATCH 06/15] docs(core): add JSDoc documentation to error classes --- packages/frames.js/src/core/errors.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/frames.js/src/core/errors.ts b/packages/frames.js/src/core/errors.ts index ea4a3fdb..801f6ccd 100644 --- a/packages/frames.js/src/core/errors.ts +++ b/packages/frames.js/src/core/errors.ts @@ -1,22 +1,36 @@ +/** + * Error thrown when the request body cannot be parsed as JSON. + * This typically occurs when the frame action payload is malformed. + */ export class RequestBodyNotJSONError extends Error { constructor() { super("Invalid frame action payload, request body is not JSON"); } } +/** + * Error thrown when the frame action payload is invalid. + * This occurs when the payload doesn't contain the expected structure. + */ export class InvalidFrameActionPayloadError extends Error { constructor(message = "Invalid frame action payload") { super(message); } } +/** + * Error class for frame message validation errors. + * Used to return user-facing error messages with appropriate HTTP status codes. + */ export class FrameMessageError extends Error { status: number; /** - * + * Creates a new FrameMessageError. * @param message - Message to show the user (up to 90 characters) - * @param status - 4XX status code + * @param status - HTTP status code (must be 4XX) + * @throws Error if message exceeds 90 characters + * @throws Error if status code is not in 4XX range */ constructor(message: string, status: number) { if (message.length > 90) throw new Error("Message too long"); @@ -26,3 +40,4 @@ export class FrameMessageError extends Error { this.status = status; } } + From eb1ffd86f7a82fd071b131fb48943a00c6107656 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:29:18 +0600 Subject: [PATCH 07/15] docs: enhance CONTRIBUTING.md with detailed setup and PR guidelines --- CONTRIBUTING.md | 106 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87244b81..65a38ec9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,113 @@ -# Set up for local development +# Contributing to frames.js -First, ensure that the following are installed globally on your machine: +Thank you for your interest in contributing to frames.js! This guide will help you get started. + +## Prerequisites + +Ensure that the following are installed globally on your machine: - Node.js 18.7+ -- Yarn +- Yarn (1.22.x) + +## Set up for local development + +1. Clone the repository: + ```bash + git clone https://github.com/framesjs/frames.js.git + cd frames.js + ``` + +2. Install dependencies: + ```bash + yarn install + ``` + +3. Run all packages in development mode: + ```bash + yarn dev + ``` + +4. To run any individual package from its directory: + ```bash + cd packages/ + yarn dev + ``` + Note: You may need to rebuild other packages first. + +## Running Tests + +Run the full test suite: +```bash +yarn test:ci +``` + +Run tests in watch mode (for development): +```bash +yarn test +``` + +## Code Style -1. In the root directory, run `yarn install` -2. Run all packages by running `yarn dev` from the root folder. -3. To run any individual package from it's directory, run `yarn dev`, but you may need to rebuild any other packages +- We use Prettier for code formatting. Run `yarn format` to format your code. +- Follow TypeScript best practices and ensure your code passes linting with `yarn lint`. +- Add JSDoc comments to public functions and classes. -# Changesets +## Changesets All PRs with meaningful changes should have a changeset which is a short description of the modifications being made to each package. Changesets are automatically converted into a changelog when the repo manager runs a release process. -## Add a new changeset +### Add a new changeset +```bash yarn changeset +``` -## Create new versions of packages +### Create new versions of packages +```bash yarn changeset version +``` -## Publish all changed packages to npm +### Publish all changed packages to npm +```bash yarn changeset publish git push --follow-tags origin main +``` + +## Pull Request Checklist + +Before submitting your PR, please ensure: + +- [ ] Code follows the project's coding standards +- [ ] All tests pass (`yarn test:ci`) +- [ ] Linting passes (`yarn lint`) +- [ ] You've added a changeset if applicable +- [ ] You've added/updated tests for your changes +- [ ] You've updated documentation if needed +- [ ] Your commit messages follow conventional commits format + +## Troubleshooting + +### TypeScript Config Issues + +If you encounter TypeScript configuration errors, try: +```bash +yarn build:ci +``` + +This will build all packages and resolve type dependencies. + +### Dependency Issues + +If packages aren't resolving correctly: +```bash +rm -rf node_modules +yarn install +``` + +## Need Help? + +- Join the [/frames-dev](https://warpcast.com/frames-dev) channel on Farcaster +- Check existing issues on GitHub +- Read the [documentation](https://framesjs.org) From b99cb3de6024f0afb638a3079a2b04b4925fc4f9 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:30:18 +0600 Subject: [PATCH 08/15] docs(middleware): add JSDoc to farcasterHubContext middleware --- .../src/middleware/farcasterHubContext.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/frames.js/src/middleware/farcasterHubContext.ts b/packages/frames.js/src/middleware/farcasterHubContext.ts index 71c13dab..8ac4fb34 100644 --- a/packages/frames.js/src/middleware/farcasterHubContext.ts +++ b/packages/frames.js/src/middleware/farcasterHubContext.ts @@ -67,6 +67,21 @@ type FramesMessageContext = { clientProtocol?: ClientProtocolId; }; +/** + * Middleware that extracts and validates Farcaster frame messages. + * Adds the decoded message and client protocol information to the context. + * Only processes POST requests with valid frame action payloads. + * + * @param options - Optional configuration for the Farcaster Hub HTTP URL + * @returns A frames middleware that adds message context + * + * @example + * ```typescript + * const frames = createFrames({ + * middleware: [farcasterHubContext()], + * }); + * ``` + */ export function farcasterHubContext( options?: HubHttpUrlOptions ): FramesMiddleware { From 14a9ae68150608931266fa7b8f6fc41e88c84b6c Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:31:30 +0600 Subject: [PATCH 09/15] docs(lib): add JSDoc to base64url and getFrame functions --- packages/frames.js/src/getFrame.ts | 9 ++++++++- packages/frames.js/src/lib/base64url.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/frames.js/src/getFrame.ts b/packages/frames.js/src/getFrame.ts index 741ba5e4..c06d42cf 100644 --- a/packages/frames.js/src/getFrame.ts +++ b/packages/frames.js/src/getFrame.ts @@ -35,8 +35,15 @@ type GetFrameOptions = { /** * Extracts frame metadata from the given htmlString. + * Parses the HTML and extracts frame information based on the specified protocol. * - * @returns an object representing the parsing result + * @param options - Configuration options for frame extraction + * @param options.htmlString - The HTML string to parse + * @param options.frameUrl - URL to the frame + * @param options.url - Fallback URL used if post_url is missing + * @param options.specification - The parsing specification (default: 'farcaster') + * @param options.fromRequestMethod - Request method used to fetch the frame (default: 'GET') + * @returns An object representing the parsing result with frame data and any reports */ export async function getFrame({ htmlString, diff --git a/packages/frames.js/src/lib/base64url.ts b/packages/frames.js/src/lib/base64url.ts index 582e2b39..db619a65 100644 --- a/packages/frames.js/src/lib/base64url.ts +++ b/packages/frames.js/src/lib/base64url.ts @@ -1,3 +1,9 @@ +/** + * Encodes a Buffer to a URL-safe base64 string. + * Replaces '+' with '-', '/' with '_', and removes padding '='. + * @param data - The buffer to encode + * @returns URL-safe base64 encoded string + */ export function base64urlEncode(data: Buffer): string { // we could use .toString('base64url') on buffer, but that throws in browser return data @@ -7,6 +13,12 @@ export function base64urlEncode(data: Buffer): string { .replace(/=/g, ""); } +/** + * Decodes a URL-safe base64 string back to a Buffer. + * Handles the reverse transformations of base64urlEncode. + * @param encodedData - The URL-safe base64 string to decode + * @returns Decoded buffer + */ export function base64urlDecode(encodedData: string): Buffer { const encodedChunks = encodedData.length % 4; const base64 = encodedData @@ -17,3 +29,4 @@ export function base64urlDecode(encodedData: string): Buffer { // we could use base64url on buffer, but that throws in browser return Buffer.from(base64, "base64"); } + From fe6b86b57a27733827e60addd18eb019c4ab5836 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:33:08 +0600 Subject: [PATCH 10/15] docs: enhance JSDoc for validateFrameMessage with examples --- packages/frames.js/src/validateFrameMessage.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/frames.js/src/validateFrameMessage.ts b/packages/frames.js/src/validateFrameMessage.ts index b715f8e4..c2a0298a 100644 --- a/packages/frames.js/src/validateFrameMessage.ts +++ b/packages/frames.js/src/validateFrameMessage.ts @@ -24,7 +24,22 @@ function isValidateMessageJson(value: unknown): value is ValidateMessageJson { } /** - * @returns a Promise that resolves with whether the message signature is valid, by querying a Farcaster hub, as well as the message itself + * Validates a frame action message by querying a Farcaster hub. + * Verifies the message signature is valid and returns the decoded message. + * + * @param body - The frame action payload containing trusted and untrusted data + * @param options - Optional hub configuration + * @param options.hubHttpUrl - The Farcaster hub HTTP URL (default: neynar hub) + * @param options.hubRequestOptions - Additional request options for the hub API + * @returns A Promise resolving to an object with isValid boolean and the message if valid + * + * @example + * ```typescript + * const { isValid, message } = await validateFrameMessage(body); + * if (isValid && message) { + * console.log('Valid message from FID:', message.data.fid); + * } + * ``` */ export async function validateFrameMessage( body: FrameActionPayload, From f241105d96c108ddfdec4d6908c0e0187294a600 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:35:45 +0600 Subject: [PATCH 11/15] docs: enhance JSDoc for getFrameHtml with parameter docs --- packages/frames.js/src/getFrameHtml.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/frames.js/src/getFrameHtml.ts b/packages/frames.js/src/getFrameHtml.ts index 267af3dc..5f31ed14 100644 --- a/packages/frames.js/src/getFrameHtml.ts +++ b/packages/frames.js/src/getFrameHtml.ts @@ -16,10 +16,16 @@ export interface GetFrameHtmlOptions { } /** - * Turns a `Frame` into html - * @param frame - The Frame to turn into html - * @param options - additional options passs into the html string - * @returns an html string + * Turns a `Frame` into a complete HTML document string. + * Generates a valid HTML page with proper meta tags for frame rendering. + * + * @param frame - The Frame to turn into HTML + * @param options - Additional options to customize the HTML output + * @param options.og - OpenGraph title configuration + * @param options.title - Custom page title + * @param options.htmlBody - Additional content for the body tag + * @param options.htmlHead - Additional content for the head tag + * @returns A complete HTML document string */ export function getFrameHtml( frame: Frame, @@ -29,11 +35,10 @@ export function getFrameHtml( ${options.title ?? frame.title ?? DEFAULT_FRAME_TITLE} - ${ - frame.title ?? options.og?.title - ? `` - : "" - } + ${frame.title ?? options.og?.title + ? `` + : "" + } ${getFrameHtmlHead(frame)} ${options.htmlHead || ""} From 28f7237bc9c59267ad54270a8062c18714247008 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:36:28 +0600 Subject: [PATCH 12/15] docs: enhance JSDoc for getFrameFlattened function --- packages/frames.js/src/getFrameFlattened.ts | 77 +++++++++++---------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/frames.js/src/getFrameFlattened.ts b/packages/frames.js/src/getFrameFlattened.ts index 2ef15d10..ee3873c0 100644 --- a/packages/frames.js/src/getFrameFlattened.ts +++ b/packages/frames.js/src/getFrameFlattened.ts @@ -12,8 +12,13 @@ export function getFrameFlattened( ): Partial; /** - * Takes a `Frame` and formats it as an intermediate step before rendering as html - * @returns a plain object with frame metadata keys and values according to the frame spec, using their lengthened syntax, e.g. "fc:frame:image" + * Takes a `Frame` and converts it to a flattened object with frame metadata keys. + * This is an intermediate step before rendering as HTML meta tags. + * + * @param frame - The frame object to flatten + * @param overrides - Optional overrides to apply to the flattened result + * @returns A plain object with frame metadata keys and values according to the frame spec, + * using their lengthened syntax, e.g. "fc:frame:image" */ export function getFrameFlattened( /** @@ -28,40 +33,40 @@ export function getFrameFlattened( const openFrames = frame.accepts && Boolean(frame.accepts.length) ? { - // custom of tags - "of:version": frame.version, - ...frame.accepts.reduce( - (acc: Record, { id, version }) => { - acc[`of:accepts:${id}`] = version; - return acc; - }, - {} - ), - // same as fc:frame tags - "of:image": frame.image, - "og:image": frame.ogImage || frame.image, - "og:title": frame.title, - "of:post_url": frame.postUrl, - "of:input:text": frame.inputText, - ...(frame.state ? { "of:state": frame.state } : {}), - ...(frame.imageAspectRatio - ? { "of:image:aspect_ratio": frame.imageAspectRatio } - : {}), - ...frame.buttons?.reduce( - (acc, button, index) => ({ - ...acc, - [`of:button:${index + 1}`]: button.label, - [`of:button:${index + 1}:action`]: button.action, - [`of:button:${index + 1}:target`]: button.target, - ...(button.action === "tx" || + // custom of tags + "of:version": frame.version, + ...frame.accepts.reduce( + (acc: Record, { id, version }) => { + acc[`of:accepts:${id}`] = version; + return acc; + }, + {} + ), + // same as fc:frame tags + "of:image": frame.image, + "og:image": frame.ogImage || frame.image, + "og:title": frame.title, + "of:post_url": frame.postUrl, + "of:input:text": frame.inputText, + ...(frame.state ? { "of:state": frame.state } : {}), + ...(frame.imageAspectRatio + ? { "of:image:aspect_ratio": frame.imageAspectRatio } + : {}), + ...frame.buttons?.reduce( + (acc, button, index) => ({ + ...acc, + [`of:button:${index + 1}`]: button.label, + [`of:button:${index + 1}:action`]: button.action, + [`of:button:${index + 1}:target`]: button.target, + ...(button.action === "tx" || button.action === "post" || button.action === "post_redirect" - ? { [`of:button:${index + 1}:post_url`]: button.post_url } - : {}), - }), - {} - ), - } + ? { [`of:button:${index + 1}:post_url`]: button.post_url } + : {}), + }), + {} + ), + } : {}; const metadata: Partial = { @@ -83,8 +88,8 @@ export function getFrameFlattened( [`fc:frame:button:${index + 1}:action`]: button.action, [`fc:frame:button:${index + 1}:target`]: button.target, ...(button.action === "tx" || - button.action === "post" || - button.action === "post_redirect" + button.action === "post" || + button.action === "post_redirect" ? { [`fc:frame:button:${index + 1}:post_url`]: button.post_url } : {}), }), From 8535368cb962a9edf72fd0e5d4b82159a6f8a38e Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:37:36 +0600 Subject: [PATCH 13/15] docs: add JSDoc to default hub configuration constants --- packages/frames.js/src/default.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/frames.js/src/default.ts b/packages/frames.js/src/default.ts index ddf62339..e8ecd9ee 100644 --- a/packages/frames.js/src/default.ts +++ b/packages/frames.js/src/default.ts @@ -1,2 +1,11 @@ +/** + * Default Farcaster Hub API URL using the Neynar hub service. + * Used when no custom hub URL is provided. + */ export const DEFAULT_HUB_API_URL = "https://hub-api.neynar.com"; + +/** + * Default API key for the Neynar hub service. + * This is a public API key for frames.js usage. + */ export const DEFAULT_HUB_API_KEY = "NEYNAR_FRAMES_JS"; From ae1ebb670fb6ae7d5400bc52f9be6893328ab8c3 Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:38:27 +0600 Subject: [PATCH 14/15] chore: add changeset for comprehensive improvements --- .changeset/comprehensive-improvements.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/comprehensive-improvements.md diff --git a/.changeset/comprehensive-improvements.md b/.changeset/comprehensive-improvements.md new file mode 100644 index 00000000..a5cfbd78 --- /dev/null +++ b/.changeset/comprehensive-improvements.md @@ -0,0 +1,11 @@ +--- +"frames.js": patch +--- + +### Improvements + +- Added comprehensive test coverage for utility functions (`escapeHtmlAttributeValue`, `isValidVersion`, button type guards, `bytesToHexString`, `getByteLength`, `hexStringToUint8Array`, `normalizeCastId`) +- Added tests for core utilities (`joinPaths`, `parseSearchParams`, `isFrameRedirect`, `isFrameDefinition`) +- Added tests for error classes (`RequestBodyNotJSONError`, `InvalidFrameActionPayloadError`, `FrameMessageError`) +- Enhanced JSDoc documentation across utility functions and middleware +- Improved CONTRIBUTING.md with detailed setup, testing, and PR guidelines From a194a1efe98c6e0dac3565b7882da376128bef0a Mon Sep 17 00:00:00 2001 From: Dark-Brain07 Date: Mon, 26 Jan 2026 09:40:04 +0600 Subject: [PATCH 15/15] docs: enhance JSDoc for parseFramesWithReports function --- .../frames.js/src/parseFramesWithReports.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/frames.js/src/parseFramesWithReports.ts b/packages/frames.js/src/parseFramesWithReports.ts index aae8bdbd..e667c8a8 100644 --- a/packages/frames.js/src/parseFramesWithReports.ts +++ b/packages/frames.js/src/parseFramesWithReports.ts @@ -36,7 +36,16 @@ type ParseFramesWithReportsOptions = { }; /** - * Gets all supported frames and validation their respective validation reports. + * Parses HTML content and extracts frame data for all supported frame specifications. + * Returns parsed frames along with validation reports for each specification. + * + * @param options - Configuration options for parsing + * @param options.html - The HTML content to parse + * @param options.frameUrl - URL of the frame + * @param options.fallbackPostUrl - URL to use if frame doesn't specify a post_url + * @param options.fromRequestMethod - Request method used ('GET' or 'POST'), affects validation + * @param options.parseSettings - Optional settings to customize parsing behavior + * @returns Parsed frames for Farcaster, Farcaster v2, and OpenFrames specifications */ export async function parseFramesWithReports({ html, @@ -81,10 +90,10 @@ export async function parseFramesWithReports({ framesVersion, ...(debugImageUrl ? { - framesDebugInfo: { - image: debugImageUrl, - }, - } + framesDebugInfo: { + image: debugImageUrl, + }, + } : {}), };