= T extends T ? keyof T : never;
-type Exact = P extends Builtin
- ? P
- : P & { [K in keyof P]: Exact
} & {
- [K in Exclude>]: never;
- };
+type Exact = P extends Builtin ? P
+ : P & { [K in keyof P]: Exact
} & { [K in Exclude>]: never };
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
- throw new tsProtoGlobalThis.Error(
- "Value is larger than Number.MAX_SAFE_INTEGER"
- );
+ throw new tsProtoGlobalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
diff --git a/packages/frames.js/src/frame-parsers/farcasterV2.test.ts b/packages/frames.js/src/frame-parsers/farcasterV2.test.ts
new file mode 100644
index 000000000..9dd3e0e6c
--- /dev/null
+++ b/packages/frames.js/src/frame-parsers/farcasterV2.test.ts
@@ -0,0 +1,1554 @@
+/* eslint-disable @typescript-eslint/no-explicit-any -- tests */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment -- tests */
+import { load } from "cheerio";
+import nock, { disableNetConnect, enableNetConnect, cleanAll } from "nock";
+import type { PartialDeep } from "type-fest";
+import type { FrameV2 } from "../types";
+import type { FarcasterManifest } from "../farcaster-v2/types";
+import { parseFarcasterFrameV2 } from "./farcasterV2";
+import { createReporter } from "./reporter";
+
+const frameUrl = "https://framesjs.org/my/frame/v2";
+
+const validFrame: FrameV2 = {
+ button: {
+ action: {
+ name: "App name",
+ splashBackgroundColor: "#000000",
+ splashImageUrl: "https://framesjs.org/logo.png",
+ url: "https://framesjs.org",
+ type: "launch_frame",
+ },
+ title: "Button title",
+ },
+ imageUrl: "https://framesjs.org/logo.png",
+ version: "next",
+};
+
+describe("farcaster frame v2 parser", () => {
+ let reporter = createReporter("farcaster_v2");
+
+ beforeEach(() => {
+ reporter = createReporter("farcaster_v2");
+
+ cleanAll();
+ disableNetConnect();
+ });
+
+ afterEach(() => {
+ enableNetConnect();
+ });
+
+ it("does not support farcaster v1 metatags", async () => {
+ const document = load(`
+
+
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ reports: {
+ "fc:frame": [
+ {
+ level: "error",
+ source: "farcaster_v2",
+ message: "Failed to parse Frame, it is not a valid JSON value",
+ },
+ ],
+ },
+ frame: {},
+ });
+ });
+
+ it('parses frame from "fc:frame" meta tag', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toEqual({
+ status: "success",
+ specification: "farcaster_v2",
+ frame: {
+ version: "next",
+ imageUrl: "https://framesjs.org/logo.png",
+ button: {
+ action: {
+ name: "App name",
+ splashBackgroundColor: "#000000",
+ splashImageUrl: "https://framesjs.org/logo.png",
+ url: "https://framesjs.org",
+ type: "launch_frame",
+ },
+ title: "Button title",
+ },
+ },
+ reports: {},
+ });
+ });
+
+ it("fails on missing version", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const { version: _, ...restOfFrame } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ },
+ reports: {
+ "fc:frame.version": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: 'Invalid literal value, expected "next"',
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ "fails to parse non string version",
+ async (version) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const { version: _, ...restOfFrame } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ },
+ reports: {
+ "fc:frame.version": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: 'Invalid literal value, expected "next"',
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it("fails on missing imageUrl", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const { imageUrl: _, ...restOfFrame } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ },
+ reports: {
+ "fc:frame.imageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Required",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ "fails to parse non string imageUrl",
+ async (imageUrl) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const { imageUrl: _, ...restOfFrame } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ },
+ reports: {
+ "fc:frame.imageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it("fails on invalid URL in imageUrl", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const { imageUrl: _, ...restOfFrame } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ },
+ reports: {
+ "fc:frame.imageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Invalid url",
+ },
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+
+ describe("button", () => {
+ it("fails on missing title", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: { title: _, ...restOfButton },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ },
+ },
+ reports: {
+ "fc:frame.button.title": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Required",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ "fails to parse non string title",
+ async (title) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: { title: _, ...restOfButton },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ },
+ },
+ reports: {
+ "fc:frame.button.title": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ describe("action", () => {
+ it("fails on missing name", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { name: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.name": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Required",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ "fails to parse non string name",
+ async (name) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { name: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.name": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it("fails on missing type", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { type: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.type": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Invalid discriminator value. Expected 'launch_frame'",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ "fails to parse non string type",
+ async (type) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { type: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.type": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message:
+ "Invalid discriminator value. Expected 'launch_frame'",
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it('fails on invalid type, must be "launch_frame"', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { type: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.type": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Invalid discriminator value. Expected 'launch_frame'",
+ },
+ ],
+ },
+ });
+ });
+
+ it("fails on missing url", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { url: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.url": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Required",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])("fails to parse non string url", async (url) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { url: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.url": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ });
+
+ it("fails if url is not valid URL", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { url: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.url": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Invalid url",
+ },
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ 'fails to parse non string "splashImageUrl"',
+ async (splashImageUrl) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { splashImageUrl: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.splashImageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it('fails on invalid "splashImageUrl" URL', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { splashImageUrl: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.splashImageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Invalid url",
+ },
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+
+ it.each([1, true, null])(
+ 'fails to parse non string "splashBackgroundColor"',
+ async (splashBackgroundColor) => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { splashBackgroundColor: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.splashBackgroundColor": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: expect.stringMatching("Expected string, received"),
+ },
+ ],
+ },
+ });
+ }
+ );
+
+ it('fails on invalid "splashBackgroundColor" color', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ const {
+ button: {
+ action: { splashBackgroundColor: _, ...restOfAction },
+ ...restOfButton
+ },
+ ...restOfFrame
+ } = validFrame;
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...restOfFrame,
+ button: {
+ ...restOfButton,
+ action: {
+ ...restOfAction,
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.splashBackgroundColor": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message:
+ "Invalid hex color code. It should be in the format #RRGGBB or #RGB.",
+ },
+ ],
+ },
+ });
+ });
+ });
+ });
+
+ describe("manifest", () => {
+ it("does not parse manifest by default", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter })
+ ).resolves.toMatchObject({
+ status: "success",
+ specification: "farcaster_v2",
+ frame: validFrame,
+ reports: {},
+ manifest: undefined,
+ });
+ });
+ });
+
+ it("fails parsing manifest if the response is not ok", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ nock("https://framesjs.org").get("/.well-known/farcaster.json").reply(404);
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl,
+ reporter,
+ parseManifest: true,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {},
+ reports: {
+ "fc:manifest": [
+ {
+ level: "error",
+ message: "Failed to fetch frame manifest, status code: 404",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it("fails parsing manifest if the response is not valid JSON", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(200, "not a json");
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl,
+ reporter,
+ parseManifest: true,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {},
+ reports: {
+ "fc:manifest": [
+ {
+ level: "error",
+ message:
+ "Failed to parse frame manifest, it is not a valid JSON value",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it("fails parsing manifest if frame config is invalid", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header: "test",
+ },
+ })
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl,
+ reporter,
+ parseManifest: true,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {
+ accountAssociation: {
+ header: "test",
+ },
+ },
+ reports: {
+ "fc:manifest.accountAssociation.payload": [
+ {
+ level: "error",
+ message: "Required",
+ source: "farcaster_v2",
+ },
+ ],
+ "fc:manifest.accountAssociation.signature": [
+ {
+ level: "error",
+ message: "Required",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it("fails validation if signature is not associated with the domain", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ enableNetConnect("mainnet.optimism.io:443");
+
+ nock("https://non-framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://framesjs.org",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ } satisfies FarcasterManifest)
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl: "https://non-framesjs.org/my/frame/v2",
+ reporter,
+ parseManifest: true,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {},
+ reports: {},
+ },
+ });
+ });
+
+ it("parses valid manifest", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ enableNetConnect("mainnet.optimism.io:443");
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://framesjs.org",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ } satisfies FarcasterManifest)
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl: "https://framesjs.org/my/frame/v2",
+ reporter,
+ parseManifest: true,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "success",
+ manifest: {
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://framesjs.org",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ },
+ reports: {},
+ },
+ });
+ });
+
+ describe("non strict mode", () => {
+ describe("button", () => {
+ it("returns error but keeps imageUrl if imageUrl is not https string", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, { frameUrl, reporter, strict: false })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...validFrame,
+ imageUrl: "http://example.com/image.png",
+ },
+ reports: {
+ "fc:frame.imageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+
+ describe("action", () => {
+ it("returns error but keeps url if url is not https string", async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl,
+ reporter,
+ strict: false,
+ })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...validFrame,
+ button: {
+ ...validFrame.button,
+ action: {
+ ...validFrame.button.action,
+ url: "http://example.com",
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.url": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+
+ it('returns error but keeps "splashImageUrl" if "splashImageUrl" is not https string', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl,
+ reporter,
+ strict: false,
+ })
+ ).resolves.toMatchObject({
+ status: "failure",
+ specification: "farcaster_v2",
+ frame: {
+ ...validFrame,
+ button: {
+ ...validFrame.button,
+ action: {
+ ...validFrame.button.action,
+ splashImageUrl: "http://example.com",
+ },
+ },
+ },
+ reports: {
+ "fc:frame.button.action.splashImageUrl": [
+ {
+ source: "farcaster_v2",
+ level: "error",
+ message: "Must be an https url",
+ },
+ ],
+ },
+ });
+ });
+ });
+ });
+
+ describe("manifest", () => {
+ describe("frame", () => {
+ it('returns an error if "homeUrl" is not an https string', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ enableNetConnect("mainnet.optimism.io:443");
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "http://example.com",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ } satisfies FarcasterManifest)
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl: "https://framesjs.org/my/frame/v2",
+ reporter,
+ parseManifest: true,
+ strict: false,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "http://example.com",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ },
+ reports: {
+ "fc:manifest.frame.homeUrl": [
+ {
+ level: "error",
+ message: "Must be an https url",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('returns an error if "iconUrl" is not an https string', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ enableNetConnect("mainnet.optimism.io:443");
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://example.com",
+ iconUrl: "http://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ } satisfies FarcasterManifest)
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl: "https://framesjs.org/my/frame/v2",
+ reporter,
+ parseManifest: true,
+ strict: false,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://example.com",
+ iconUrl: "http://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ },
+ },
+ reports: {
+ "fc:manifest.frame.iconUrl": [
+ {
+ level: "error",
+ message: "Must be an https url",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('returns an error if "splashImageUrl" is not an https string', async () => {
+ const document = load(`
+
+ Test
+ `);
+
+ enableNetConnect("mainnet.optimism.io:443");
+
+ nock("https://framesjs.org")
+ .get("/.well-known/farcaster.json")
+ .reply(
+ 200,
+ JSON.stringify({
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://example.com",
+ iconUrl: "https://framesjs.org/logo.png",
+ name: "App name",
+ version: "next",
+ splashImageUrl: "http://example.com/splash.png",
+ },
+ } satisfies FarcasterManifest)
+ );
+
+ await expect(
+ parseFarcasterFrameV2(document, {
+ frameUrl: "https://framesjs.org/my/frame/v2",
+ reporter,
+ parseManifest: true,
+ strict: false,
+ })
+ ).resolves.toMatchObject({
+ status: "success",
+ manifest: {
+ status: "failure",
+ manifest: {
+ accountAssociation: {
+ header:
+ "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ",
+ payload: "eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ",
+ signature:
+ "MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi",
+ },
+ frame: {
+ homeUrl: "https://example.com",
+ iconUrl: "https://framesjs.org/logo.png",
+ splashImageUrl: "http://example.com/splash.png",
+ name: "App name",
+ version: "next",
+ },
+ },
+ reports: {
+ "fc:manifest.frame.splashImageUrl": [
+ {
+ level: "error",
+ message: "Must be an https url",
+ source: "farcaster_v2",
+ },
+ ],
+ },
+ },
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/packages/frames.js/src/frame-parsers/farcasterV2.ts b/packages/frames.js/src/frame-parsers/farcasterV2.ts
new file mode 100644
index 000000000..ed8d7bde3
--- /dev/null
+++ b/packages/frames.js/src/frame-parsers/farcasterV2.ts
@@ -0,0 +1,411 @@
+import type { CheerioAPI } from "cheerio";
+import {
+ frameEmbedNextSchema,
+ domainManifestSchema,
+} from "@farcaster/frame-core";
+import { z } from "zod";
+import type { FarcasterManifest } from "../farcaster-v2/types";
+import { decodePayload, verify } from "../farcaster-v2/json-signature";
+import { getMetaTag, removeInvalidDataFromObject } from "./utils";
+import type {
+ ParseResultFramesV2,
+ ParseResultFramesV2FrameManifest,
+ Reporter,
+} from "./types";
+import { createReporter } from "./reporter";
+
+// @todo find out how to report that url is not secure but still keep it valid
+// maybe do this by using our own issue code which we will filter out before parsing partial data
+// this will make sure that manifest/frame status is failure but data is there
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- hard to type and we can infer because this is internal function, not exported
+function createDomainManifestParser(strict: boolean, reporter: Reporter) {
+ if (!strict) {
+ const nonStrictDomainManifestSchema = domainManifestSchema.extend({
+ frame: domainManifestSchema.shape.frame
+ .unwrap()
+ .extend({
+ iconUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:manifest.frame.iconUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ }),
+ homeUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:manifest.frame.homeUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ }),
+ imageUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:manigest.frame.imageUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ })
+ .optional(),
+ splashImageUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:manifest.frame.splashImageUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ })
+ .optional(),
+ webhookUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:manifest.frame.webhookUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ })
+ .optional(),
+ })
+ .optional(),
+ });
+
+ return nonStrictDomainManifestSchema;
+ }
+
+ return domainManifestSchema;
+}
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- hard to type and we can infer because this is internal function, not exported
+function createFrameEmbedParser(strict: boolean, reporter: Reporter) {
+ if (!strict) {
+ const nonStrictFrameEmbedNextSchema = frameEmbedNextSchema.extend({
+ imageUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error("fc:frame.imageUrl", "Must be an https url");
+ }
+
+ return val;
+ }),
+ button: frameEmbedNextSchema.shape.button.extend({
+ action: z.discriminatedUnion("type", [
+ frameEmbedNextSchema.shape.button.shape.action.options[0].extend({
+ url: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:frame.button.action.url",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ }),
+ splashImageUrl: z
+ .string()
+ .url()
+ .transform((val) => {
+ if (!val.startsWith("https://")) {
+ reporter.error(
+ "fc:frame.button.action.splashImageUrl",
+ "Must be an https url"
+ );
+ }
+
+ return val;
+ })
+ .optional(),
+ }),
+ ]),
+ }),
+ });
+
+ return nonStrictFrameEmbedNextSchema;
+ }
+
+ return frameEmbedNextSchema;
+}
+
+export type ParseFarcasterFrameV2ValidationSettings = {
+ /**
+ * Enable/disable frame manifest parsing.
+ *
+ * @see https://docs.farcaster.xyz/developers/frames/v2/spec#frame-manifest
+ *
+ * @defaultValue false
+ */
+ parseManifest?: boolean;
+ /**
+ * Allows you to disable strict mode
+ *
+ * Strict mode check if all urls are secure (https://) and if they are valid urls
+ *
+ * @defaultValue true
+ */
+ strict?: boolean;
+};
+
+type ParseFarcasterFrameV2Options = {
+ /**
+ * URL of the frame
+ *
+ * This is used for manifest validation.
+ */
+ frameUrl: string;
+ reporter: Reporter;
+} & ParseFarcasterFrameV2ValidationSettings;
+
+export async function parseFarcasterFrameV2(
+ $: CheerioAPI,
+ {
+ frameUrl,
+ reporter,
+ parseManifest: parseManifestEnabled = false,
+ strict = true,
+ }: ParseFarcasterFrameV2Options
+): Promise {
+ const embed = getMetaTag($, "fc:frame");
+
+ if (!embed) {
+ reporter.error("fc:frame", 'Missing required meta tag "fc:frame"');
+
+ return {
+ status: "failure",
+ frame: {},
+ reports: reporter.toObject(),
+ specification: "farcaster_v2",
+ };
+ }
+
+ let parsedJSON: unknown;
+
+ try {
+ parsedJSON = JSON.parse(embed);
+ } catch (error) {
+ reporter.error(
+ "fc:frame",
+ "Failed to parse Frame, it is not a valid JSON value"
+ );
+
+ return {
+ status: "failure",
+ frame: {},
+ reports: reporter.toObject(),
+ specification: "farcaster_v2",
+ };
+ }
+
+ const parser = createFrameEmbedParser(strict, reporter);
+ const frameEmbedParseResult = parser.safeParse(parsedJSON);
+
+ if (!frameEmbedParseResult.success) {
+ for (const error of frameEmbedParseResult.error.errors) {
+ if (error.path.length > 0) {
+ reporter.error(`fc:frame.${error.path.join(".")}`, error.message);
+ } else {
+ reporter.error("fc:frame", error.message);
+ }
+ }
+
+ return {
+ status: "failure",
+ frame: removeInvalidDataFromObject(
+ parsedJSON,
+ frameEmbedParseResult.error
+ ),
+ reports: reporter.toObject(),
+ specification: "farcaster_v2",
+ };
+ }
+
+ return {
+ status: reporter.hasErrors() ? "failure" : "success",
+ frame: frameEmbedParseResult.data,
+ reports: reporter.toObject(),
+ specification: "farcaster_v2",
+ manifest: parseManifestEnabled
+ ? await parseManifest(frameUrl, strict)
+ : undefined,
+ };
+}
+
+async function parseManifest(
+ frameUrl: string,
+ strict: boolean
+): Promise {
+ const reporter = createReporter("farcaster_v2");
+ // load manifest from well known URI
+ try {
+ const manifestResponse = await fetch(
+ new URL("/.well-known/farcaster.json", frameUrl),
+ {
+ method: "GET",
+ cache: "no-cache",
+ headers: {
+ Accept: "application/json",
+ },
+ }
+ );
+
+ if (!manifestResponse.ok) {
+ reporter.error(
+ "fc:manifest",
+ `Failed to fetch frame manifest, status code: ${manifestResponse.status}`
+ );
+
+ return {
+ status: "failure",
+ manifest: {},
+ reports: reporter.toObject(),
+ };
+ }
+
+ const body: unknown = await manifestResponse.json();
+ const parser = createDomainManifestParser(strict, reporter);
+ const manifestParseResult = parser.safeParse(body);
+
+ if (!manifestParseResult.success) {
+ for (const error of manifestParseResult.error.errors) {
+ if (error.path.length > 0) {
+ reporter.error(`fc:manifest.${error.path.join(".")}`, error.message);
+ } else {
+ reporter.error("fc:manifest", error.message);
+ }
+ }
+
+ return {
+ status: "failure",
+ manifest: removeInvalidDataFromObject(
+ body,
+ manifestParseResult.error
+ ),
+ reports: reporter.toObject(),
+ };
+ }
+
+ await verifyManifestAccountAssociation(
+ manifestParseResult.data,
+ frameUrl,
+ reporter
+ );
+
+ if (reporter.hasErrors()) {
+ return {
+ status: "failure",
+ manifest: manifestParseResult.data,
+ reports: reporter.toObject(),
+ };
+ }
+
+ return {
+ status: "success",
+ manifest: manifestParseResult.data,
+ reports: reporter.toObject(),
+ };
+ } catch (e) {
+ if (e instanceof Error) {
+ reporter.error(
+ "fc:manifest",
+ `Failed to parse frame manifest: ${String(e)}`
+ );
+ } else {
+ const message = String(e);
+
+ if (message.startsWith("SyntaxError")) {
+ reporter.error(
+ "fc:manifest",
+ "Failed to parse frame manifest, it is not a valid JSON value"
+ );
+ } else {
+ reporter.error(
+ "fc:manifest",
+ `Failed to fetch frame manifest: ${message}`
+ );
+ }
+ }
+
+ return {
+ status: "failure",
+ manifest: {},
+ reports: reporter.toObject(),
+ };
+ }
+}
+
+async function verifyManifestAccountAssociation(
+ manifest: FarcasterManifest,
+ frameUrl: string,
+ reporter: Reporter
+): Promise {
+ const domain = new URL(frameUrl).hostname;
+
+ const isValid = await verify(manifest.accountAssociation);
+
+ if (!isValid) {
+ reporter.error(
+ "fc:manifest.accountAssociation",
+ "Failed to verify account association signature"
+ );
+ return;
+ }
+
+ const parsedPayload = decodePayload(manifest.accountAssociation.payload);
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it could be anything
+ if (typeof parsedPayload !== "object" || parsedPayload === null) {
+ reporter.error(
+ "fc:manifest.accountAssociation.payload",
+ "Failed to parse account association payload"
+ );
+ return;
+ }
+
+ if (!("domain" in parsedPayload)) {
+ reporter.error(
+ "fc:manifest.accountAssociation.payload",
+ "Missing required property 'domain' in account association payload"
+ );
+ } else if (typeof parsedPayload.domain !== "string") {
+ reporter.error(
+ "fc:manifest.accountAssociation.payload",
+ "Account association payload domain must be a string"
+ );
+ } else if (parsedPayload.domain !== domain) {
+ reporter.error(
+ "fc:manifest.accountAssociation.payload",
+ "Account association payload domain must match the frame URL"
+ );
+ }
+}
diff --git a/packages/frames.js/src/frame-parsers/types.ts b/packages/frames.js/src/frame-parsers/types.ts
index f47886fae..0ee868f99 100644
--- a/packages/frames.js/src/frame-parsers/types.ts
+++ b/packages/frames.js/src/frame-parsers/types.ts
@@ -1,6 +1,14 @@
-import type { Frame } from "../types";
+import type { PartialDeep } from "type-fest";
+import type { Frame, FrameV2 } from "../types";
+import type {
+ FarcasterManifest,
+ PartialFarcasterManifest,
+} from "../farcaster-v2/types";
-export type SupportedParsingSpecification = "farcaster" | "openframes";
+export type SupportedParsingSpecification =
+ | "farcaster"
+ | "farcaster_v2"
+ | "openframes";
export interface Reporter {
error: (key: string, message: unknown, source?: ParsingReportSource) => void;
@@ -33,6 +41,8 @@ export type ParsedFrame = {
title?: string;
};
+export type ParsedFrameV2 = PartialDeep;
+
export type ParsingReportSource = SupportedParsingSpecification;
export type ParsingReportLevel = "error" | "warning";
@@ -43,25 +53,77 @@ export type ParsingReport = {
level: ParsingReportLevel;
};
+export type ParseResultFramesV1Success = {
+ status: "success";
+ frame: Frame;
+ /**
+ * Reports contain only warnings that should not have any impact on the frame's functionality.
+ */
+ reports: Record;
+ specification: "farcaster" | "openframes";
+};
+
+export type ParseResultFramesV1Failure = {
+ status: "failure";
+ frame: Partial;
+ /**
+ * Reports contain warnings and errors that should be addressed before the frame can be used.
+ */
+ reports: Record;
+ specification: "farcaster" | "openframes";
+};
+
export type ParseResult =
- | {
- status: "success";
- frame: Frame;
- /**
- * Reports contain only warnings that should not have any impact on the frame's functionality.
- */
- reports: Record;
- specification: SupportedParsingSpecification;
- }
- | {
- status: "failure";
- frame: Partial;
- /**
- * Reports contain warnings and errors that should be addressed before the frame can be used.
- */
- reports: Record;
- specification: SupportedParsingSpecification;
- };
+ | ParseResultFramesV1Success
+ | ParseResultFramesV1Failure;
+
+export type ParseResultFramesV2FrameManifestSuccess = {
+ status: "success";
+ manifest: FarcasterManifest;
+ reports: Record;
+};
+
+export type ParseResultFramesV2FrameManifestFailure = {
+ status: "failure";
+ manifest: PartialFarcasterManifest;
+ reports: Record;
+};
+
+export type ParseResultFramesV2FrameManifest =
+ | ParseResultFramesV2FrameManifestSuccess
+ | ParseResultFramesV2FrameManifestFailure;
+
+export type ParseResultFramesV2Success = {
+ status: "success";
+ frame: FrameV2;
+ /**
+ * Reports contain only warnings that should not have any impact on the frame's functionality.
+ */
+ reports: Record;
+ specification: "farcaster_v2";
+ /**
+ * Manifest parsing result, available only if parseManifest option is enabled.
+ */
+ manifest?: ParseResultFramesV2FrameManifest;
+};
+
+export type ParseResultFramesV2Failure = {
+ status: "failure";
+ frame: PartialDeep;
+ /**
+ * Reports contain warnings and errors that should be addressed before the frame can be used.
+ */
+ reports: Record;
+ specification: "farcaster_v2";
+ /**
+ * Manifest parsing result, available only if parseManifest option is enabled.
+ */
+ manifest?: ParseResultFramesV2FrameManifest;
+};
+
+export type ParseResultFramesV2 =
+ | ParseResultFramesV2Success
+ | ParseResultFramesV2Failure;
export type ParsedFrameworkDetails = {
framesVersion?: string;
@@ -73,9 +135,26 @@ export type ParsedFrameworkDetails = {
};
};
-export type ParseResultWithFrameworkDetails = ParseResult &
- ParsedFrameworkDetails;
+export type ParseFramesV1SuccessResultWithFrameworkDetails =
+ ParseResultFramesV1Success & ParsedFrameworkDetails;
+export type ParseFramesV1FailureResultWithFrameworkDetails =
+ ParseResultFramesV1Failure & ParsedFrameworkDetails;
+
+export type ParseResultWithFrameworkDetails =
+ | ParseFramesV1SuccessResultWithFrameworkDetails
+ | ParseFramesV1FailureResultWithFrameworkDetails;
+
+export type ParseFramesV2SuccessResultWithFrameworkDetails =
+ ParseResultFramesV2Success & ParsedFrameworkDetails;
+export type ParseFramesV2FailureResultWithFrameworkDetails =
+ ParseResultFramesV2Failure & ParsedFrameworkDetails;
+
+export type ParseFramesV2ResultWithFrameworkDetails =
+ | ParseFramesV2SuccessResultWithFrameworkDetails
+ | ParseFramesV2FailureResultWithFrameworkDetails;
export type ParseFramesWithReportsResult = {
- [K in SupportedParsingSpecification]: ParseResultWithFrameworkDetails;
+ farcaster: ParseResultWithFrameworkDetails;
+ farcaster_v2: ParseFramesV2ResultWithFrameworkDetails;
+ openframes: ParseResultWithFrameworkDetails;
};
diff --git a/packages/frames.js/src/frame-parsers/utils.ts b/packages/frames.js/src/frame-parsers/utils.ts
index c23e2b4c5..841a0fd56 100644
--- a/packages/frames.js/src/frame-parsers/utils.ts
+++ b/packages/frames.js/src/frame-parsers/utils.ts
@@ -1,9 +1,57 @@
import type { CheerioAPI } from "cheerio";
+import type { PartialDeep } from "type-fest";
+import type { z } from "zod";
import type { FrameButton, ImageAspectRatio } from "../types";
import { getByteLength } from "../utils";
import { getTokenFromUrl } from "../getTokenFromUrl";
import type { ParsedButton, Reporter } from "./types";
+function removePropertyByPath(path: (string | number)[], obj: unknown): void {
+ if (typeof obj !== "object" && !Array.isArray(obj)) {
+ return;
+ }
+
+ if (!obj) {
+ return;
+ }
+
+ if (path.length === 0) {
+ return;
+ }
+
+ const [property, ...rest] = path;
+
+ if (property) {
+ if (rest.length === 0) {
+ // @ts-expect-error -- we are just marking the whole thing as undefined
+ obj[path[0] as unkown as string | number] = undefined;
+ } else {
+ removePropertyByPath(
+ rest,
+ // @ts-expect-error -- property is checked and also obj is checked
+ obj[property]
+ );
+ }
+ }
+}
+
+export function removeInvalidDataFromObject(
+ data: unknown,
+ error: z.ZodError>
+): PartialDeep> {
+ if (typeof data !== "object" || !data) {
+ return {} as PartialDeep>;
+ }
+
+ const cloned = structuredClone(data);
+
+ for (const err of error.errors) {
+ removePropertyByPath(err.path, cloned);
+ }
+
+ return cloned as PartialDeep>;
+}
+
export function validate any>(
reporter: Reporter,
errorKey: string,
diff --git a/packages/frames.js/src/getFrame.test.ts b/packages/frames.js/src/getFrame.test.ts
index 411823aab..b27a8a06f 100644
--- a/packages/frames.js/src/getFrame.test.ts
+++ b/packages/frames.js/src/getFrame.test.ts
@@ -4,7 +4,7 @@ import { getFrameHtml } from "./getFrameHtml";
import type { Frame } from "./types";
describe("getFrame", () => {
- it("should parse html meta tags (farcaster)", () => {
+ it("should parse html meta tags (farcaster)", async () => {
const htmlString = `
@@ -18,12 +18,13 @@ describe("getFrame", () => {
test
`;
- expect(
+ await expect(
getFrame({
htmlString,
url: "https://example.com",
+ frameUrl: "https://example.com",
})
- ).toEqual({
+ ).resolves.toEqual({
status: "success",
framesVersion: undefined,
specification: "farcaster",
@@ -61,7 +62,7 @@ describe("getFrame", () => {
});
});
- it("should parse button actions (farcaster)", () => {
+ it("should parse button actions (farcaster)", async () => {
const html = `
@@ -81,9 +82,10 @@ describe("getFrame", () => {
const frame = getFrame({
htmlString: html,
url: "https://example.com",
+ frameUrl: "https://example.com",
});
- expect(frame).toEqual({
+ await expect(frame).resolves.toEqual({
status: "success",
framesVersion: undefined,
specification: "farcaster",
@@ -120,7 +122,7 @@ describe("getFrame", () => {
});
});
- it("should convert a Farcaster Frame HTML into a Frame object", () => {
+ it("should convert a Farcaster Frame HTML into a Frame object", async () => {
const exampleFrame: Frame = {
version: "vNext",
image: "http://example.com/image.png",
@@ -158,9 +160,10 @@ describe("getFrame", () => {
const html = getFrameHtml(exampleFrame, { title: "Test" });
- const parsedFrame = getFrame({
+ const parsedFrame = await getFrame({
htmlString: html,
url: "https://example.com",
+ frameUrl: "https://example.com",
});
expect(parsedFrame).toEqual({
@@ -172,7 +175,7 @@ describe("getFrame", () => {
});
});
- it("should parse open frames tags", () => {
+ it("should parse open frames tags", async () => {
const html = `
@@ -187,10 +190,11 @@ describe("getFrame", () => {
test
`;
- const frame = getFrame({
+ const frame = await getFrame({
htmlString: html,
url: "https://example.com",
specification: "openframes",
+ frameUrl: "https://example.com",
});
expect(frame).not.toBeNull();
@@ -233,7 +237,7 @@ describe("getFrame", () => {
});
});
- it("should parse values with escaped html values", () => {
+ it("should parse values with escaped html values", async () => {
const html = `
@@ -242,7 +246,15 @@ describe("getFrame", () => {
test
`;
- const result = getFrame({ htmlString: html, url: "https://example.com" });
+ const result = await getFrame({
+ htmlString: html,
+ frameUrl: "https://example.com",
+ url: "https://example.com",
+ });
+
+ if (result.specification !== "farcaster") {
+ throw new Error("Specification should be farcaster");
+ }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is test
expect(JSON.parse(result.frame.state!)).toEqual({
@@ -250,7 +262,7 @@ describe("getFrame", () => {
});
});
- it("should parse frames.js version from meta tags", () => {
+ it("should parse frames.js version from meta tags", async () => {
const html = `
@@ -260,12 +272,16 @@ describe("getFrame", () => {
test
`;
- const result = getFrame({ htmlString: html, url: "https://example.com" });
+ const result = await getFrame({
+ htmlString: html,
+ url: "https://example.com",
+ frameUrl: "https://example.com",
+ });
expect(result.framesVersion).toEqual("1.0.0");
});
- it("should parse a frame that does not have an og:image tag", () => {
+ it("should parse a frame that does not have an og:image tag", async () => {
const htmlString = `
@@ -278,9 +294,10 @@ describe("getFrame", () => {
test
`;
- const parseResult = getFrame({
+ const parseResult = await getFrame({
htmlString,
url: "https://example.com",
+ frameUrl: "https://example.com",
});
expect(parseResult).toEqual({
@@ -329,7 +346,7 @@ describe("getFrame", () => {
});
});
- it("should parse button post_url (farcaster)", () => {
+ it("should parse button post_url (farcaster)", async () => {
const html = `
@@ -338,9 +355,10 @@ describe("getFrame", () => {
test
`;
- const frame = getFrame({
+ const frame = await getFrame({
htmlString: html,
url: "https://example.com",
+ frameUrl: "https://example.com",
});
expect(frame).toEqual({
diff --git a/packages/frames.js/src/getFrame.ts b/packages/frames.js/src/getFrame.ts
index 130c86c07..741ba5e46 100644
--- a/packages/frames.js/src/getFrame.ts
+++ b/packages/frames.js/src/getFrame.ts
@@ -1,13 +1,20 @@
import type {
SupportedParsingSpecification,
ParseResultWithFrameworkDetails,
+ ParseFramesV2ResultWithFrameworkDetails,
} from "./frame-parsers/types";
import { parseFramesWithReports } from "./parseFramesWithReports";
-export type GetFrameResult = ParseResultWithFrameworkDetails;
+export type GetFrameResult =
+ | ParseResultWithFrameworkDetails
+ | ParseFramesV2ResultWithFrameworkDetails;
type GetFrameOptions = {
htmlString: string;
+ /**
+ * URL to the frame.
+ */
+ frameUrl: string;
/**
* Fallback url used if post_url is missing.
*/
@@ -31,13 +38,15 @@ type GetFrameOptions = {
*
* @returns an object representing the parsing result
*/
-export function getFrame({
+export async function getFrame({
htmlString,
+ frameUrl,
specification = "farcaster",
url,
fromRequestMethod = "GET",
-}: GetFrameOptions): GetFrameResult {
- const parsedFrames = parseFramesWithReports({
+}: GetFrameOptions): Promise {
+ const parsedFrames = await parseFramesWithReports({
+ frameUrl,
fallbackPostUrl: url,
html: htmlString,
fromRequestMethod,
diff --git a/packages/frames.js/src/getFrameFlattened.ts b/packages/frames.js/src/getFrameFlattened.ts
index 04e02c537..2ef15d109 100644
--- a/packages/frames.js/src/getFrameFlattened.ts
+++ b/packages/frames.js/src/getFrameFlattened.ts
@@ -1,5 +1,6 @@
import { version as framesjsVersion } from "../package.json";
-import type { Frame, FrameFlattened } from "./types";
+import type { ParsedFrameV2 } from "./frame-parsers";
+import type { Frame, FrameFlattened, FrameV2Flattened } from "./types";
export function getFrameFlattened(
frame: Frame,
@@ -96,3 +97,17 @@ export function getFrameFlattened(
return metadata;
}
+
+/**
+ * Formats a Frame v2 and formats it as an intermediate step before rendering as html
+ */
+export function getFrameV2Flattened(
+ frame: ParsedFrameV2,
+ overrides?: Partial
+): Partial {
+ return {
+ "fc:frame": JSON.stringify(frame),
+ [`frames.js:version`]: framesjsVersion,
+ ...overrides,
+ };
+}
diff --git a/packages/frames.js/src/getFrameHtml.test.ts b/packages/frames.js/src/getFrameHtml.test.ts
index f814d829f..dc37992dd 100644
--- a/packages/frames.js/src/getFrameHtml.test.ts
+++ b/packages/frames.js/src/getFrameHtml.test.ts
@@ -3,7 +3,7 @@ import { getFrameHtmlHead } from "./getFrameHtml";
import type { Frame } from "./types";
describe("getFrameHtmlHead", () => {
- it("correctly serializes JSON containing single quotes", () => {
+ it("correctly serializes JSON containing single quotes", async () => {
const json = { test: "'><&" };
const frame: Frame = {
image: "https://example.com/image.jpg",
@@ -17,13 +17,23 @@ describe("getFrameHtmlHead", () => {
''
);
- const result = getFrame({ htmlString: html, url: "http://framesjs.org" });
+ const result = await getFrame({
+ htmlString: html,
+ url: "http://framesjs.org",
+ frameUrl: "http://framesjs.org",
+ });
+
+ if (result.specification !== "farcaster") {
+ throw new Error(
+ `Expected result to be a Farcaster frame but got ${result.specification}`
+ );
+ }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is test
expect(JSON.parse(result.frame.state!)).toEqual(json);
});
- it("correctly serializes JSON containing double quotes", () => {
+ it("correctly serializes JSON containing double quotes", async () => {
const json = { test: '"><&' };
const frame: Frame = {
image: "https://example.com/image.jpg",
@@ -37,13 +47,23 @@ describe("getFrameHtmlHead", () => {
''
);
- const result = getFrame({ htmlString: html, url: "http://framesjs.org" });
+ const result = await getFrame({
+ htmlString: html,
+ url: "http://framesjs.org",
+ frameUrl: "http://framesjs.org",
+ });
+
+ if (result.specification !== "farcaster") {
+ throw new Error(
+ `Expected result to be a Farcaster frame but got ${result.specification}`
+ );
+ }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is test
expect(JSON.parse(result.frame.state!)).toEqual(json);
});
- it("correctly serializes and deserializes text input containing single quotes", () => {
+ it("correctly serializes and deserializes text input containing single quotes", async () => {
const inputText = "'test''''";
const frame: Frame = {
image: "https://example.com/image.jpg",
@@ -57,12 +77,16 @@ describe("getFrameHtmlHead", () => {
''
);
- const result = getFrame({ htmlString: html, url: "http://framesjs.org" });
+ const result = await getFrame({
+ htmlString: html,
+ url: "http://framesjs.org",
+ frameUrl: "http://framesjs.org",
+ });
expect(result.frame).toMatchObject(frame);
});
- it("correctly serializes and deserializes text input containing double quoes", () => {
+ it("correctly serializes and deserializes text input containing double quoes", async () => {
const inputText = '"test""""';
const frame: Frame = {
image: "https://example.com/image.jpg",
@@ -76,7 +100,11 @@ describe("getFrameHtmlHead", () => {
''
);
- const result = getFrame({ htmlString: html, url: "http://framesjs.org" });
+ const result = await getFrame({
+ htmlString: html,
+ url: "http://framesjs.org",
+ frameUrl: "http://framesjs.org",
+ });
expect(result.frame).toMatchObject(frame);
});
diff --git a/packages/frames.js/src/getFrameHtml.ts b/packages/frames.js/src/getFrameHtml.ts
index 3eacc9eec..267af3dcf 100644
--- a/packages/frames.js/src/getFrameHtml.ts
+++ b/packages/frames.js/src/getFrameHtml.ts
@@ -1,6 +1,7 @@
import { DEFAULT_FRAME_TITLE } from "./constants";
-import { getFrameFlattened } from "./getFrameFlattened";
-import type { Frame, FrameFlattened } from "./types";
+import type { ParsedFrameV2 } from "./frame-parsers";
+import { getFrameFlattened, getFrameV2Flattened } from "./getFrameFlattened";
+import type { Frame, FrameFlattened, FrameV2Flattened } from "./types";
import { escapeHtmlAttributeValue } from "./utils";
export interface GetFrameHtmlOptions {
@@ -67,3 +68,23 @@ export function getFrameHtmlHead(
return tags.join("");
}
+
+/**
+ * Formats a Frame v2 ready to be included in a of an html string
+ */
+export function getFrameV2HtmlHead(
+ frame: ParsedFrameV2,
+ overrides?: Partial
+): string {
+ const flattened = getFrameV2Flattened(frame, overrides);
+
+ const tagStrings = Object.entries(flattened)
+ .map(([key, value]) => {
+ return value
+ ? ``
+ : null;
+ })
+ .filter(Boolean) as string[];
+
+ return tagStrings.join("");
+}
diff --git a/packages/frames.js/src/lib/base64url.test.ts b/packages/frames.js/src/lib/base64url.test.ts
new file mode 100644
index 000000000..030fb9716
--- /dev/null
+++ b/packages/frames.js/src/lib/base64url.test.ts
@@ -0,0 +1,21 @@
+import { base64urlDecode, base64urlEncode } from "./base64url";
+
+describe("base64urlEncode", () => {
+ it('works the same as native buffer toString("base64url")', () => {
+ const data = "hello world";
+
+ expect(base64urlEncode(data)).toEqual(
+ Buffer.from(data, "utf-8").toString("base64url")
+ );
+ });
+});
+
+describe("base64urlDecode", () => {
+ it('decodes the same as native buffer from("base64url")', () => {
+ const data = base64urlEncode("hello world");
+
+ expect(base64urlDecode(data)).toEqual(
+ Buffer.from(data, "base64url").toString("utf-8")
+ );
+ });
+});
diff --git a/packages/frames.js/src/lib/base64url.ts b/packages/frames.js/src/lib/base64url.ts
new file mode 100644
index 000000000..9ad308882
--- /dev/null
+++ b/packages/frames.js/src/lib/base64url.ts
@@ -0,0 +1,19 @@
+export function base64urlEncode(data: string): string {
+ // we could use .toString('base64url') on buffer, but that throws in browser
+ return Buffer.from(data, "utf-8")
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+}
+
+export function base64urlDecode(encodedData: string): string {
+ const encodedChunks = encodedData.length % 4;
+ const base64 = encodedData
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .padEnd(encodedData.length + Math.max(0, 4 - encodedChunks), "=");
+
+ // we could use base64url on buffer, but that throws in browser
+ return Buffer.from(base64, "base64").toString("utf-8");
+}
diff --git a/packages/frames.js/src/parseFramesWithReports.test.ts b/packages/frames.js/src/parseFramesWithReports.test.ts
index 7a98deb29..8591933a4 100644
--- a/packages/frames.js/src/parseFramesWithReports.test.ts
+++ b/packages/frames.js/src/parseFramesWithReports.test.ts
@@ -1,7 +1,7 @@
import { parseFramesWithReports } from "./parseFramesWithReports";
describe("parseFramesWithReports", () => {
- it("parses available frames from html string (fallback to farcaster)", () => {
+ it("parses available frames from html string (fallback to farcaster)", async () => {
const html = `
@@ -13,12 +13,13 @@ describe("parseFramesWithReports", () => {
Test
`;
- const result = parseFramesWithReports({
+ const result = await parseFramesWithReports({
html,
fallbackPostUrl: "https://example.com",
+ frameUrl: "https://example.com/",
});
- expect(result).toEqual({
+ expect(result).toMatchObject({
farcaster: {
frame: {
image: "http://example.com/image.png",
@@ -33,6 +34,9 @@ describe("parseFramesWithReports", () => {
specification: "farcaster",
framesVersion: undefined,
},
+ farcaster_v2: {
+ status: "failure",
+ },
openframes: {
frame: {
accepts: [{ id: "farcaster", version: "vNext" }],
@@ -51,7 +55,7 @@ describe("parseFramesWithReports", () => {
});
});
- it("parses available frames from html string", () => {
+ it("parses available frames from html string", async () => {
const html = `
@@ -66,12 +70,13 @@ describe("parseFramesWithReports", () => {
Test
`;
- const result = parseFramesWithReports({
+ const result = await parseFramesWithReports({
html,
fallbackPostUrl: "https://example.com",
+ frameUrl: "https://example.com/",
});
- expect(result).toEqual({
+ expect(result).toMatchObject({
farcaster: {
frame: {
image: "http://example.com/image.png",
@@ -86,6 +91,9 @@ describe("parseFramesWithReports", () => {
framesVersion: undefined,
status: "success",
},
+ farcaster_v2: {
+ status: "failure",
+ },
openframes: {
frame: {
accepts: [{ id: "some", version: "vNext" }],
diff --git a/packages/frames.js/src/parseFramesWithReports.ts b/packages/frames.js/src/parseFramesWithReports.ts
index c9932a8d9..aae8bdbd8 100644
--- a/packages/frames.js/src/parseFramesWithReports.ts
+++ b/packages/frames.js/src/parseFramesWithReports.ts
@@ -7,9 +7,14 @@ import type {
import { parseFarcasterFrame } from "./frame-parsers/farcaster";
import { parseOpenFramesFrame } from "./frame-parsers/open-frames";
import { FRAMESJS_DEBUG_INFO_IMAGE_KEY } from "./constants";
+import {
+ parseFarcasterFrameV2,
+ type ParseFarcasterFrameV2ValidationSettings,
+} from "./frame-parsers/farcasterV2";
type ParseFramesWithReportsOptions = {
html: string;
+ frameUrl: string;
/**
* URL used if frame doesn't contain a post_url meta tag.
*/
@@ -22,17 +27,26 @@ type ParseFramesWithReportsOptions = {
* @defaultValue 'GET'
*/
fromRequestMethod?: "GET" | "POST";
+ /**
+ * Control parse settings
+ */
+ parseSettings?: {
+ farcaster_v2?: ParseFarcasterFrameV2ValidationSettings;
+ };
};
/**
* Gets all supported frames and validation their respective validation reports.
*/
-export function parseFramesWithReports({
+export async function parseFramesWithReports({
html,
+ frameUrl,
fallbackPostUrl,
fromRequestMethod = "GET",
-}: ParseFramesWithReportsOptions): ParseFramesWithReportsResult {
+ parseSettings,
+}: ParseFramesWithReportsOptions): Promise {
const farcasterReporter = createReporter("farcaster");
+ const farcasterV2Reporter = createReporter("farcaster_v2");
const openFramesReporter = createReporter("openframes");
const document = loadDocument(html);
@@ -42,6 +56,12 @@ export function parseFramesWithReports({
fromRequestMethod,
});
+ const farcasterV2 = await parseFarcasterFrameV2(document, {
+ ...parseSettings?.farcaster_v2,
+ reporter: farcasterV2Reporter,
+ frameUrl,
+ });
+
const framesVersion = document(
"meta[name='frames.js:version'], meta[property='frames.js:version']"
).attr("content");
@@ -73,6 +93,10 @@ export function parseFramesWithReports({
...farcaster,
...frameworkDetails,
},
+ farcaster_v2: {
+ ...farcasterV2,
+ ...frameworkDetails,
+ },
openframes: {
...openframes,
...frameworkDetails,
diff --git a/packages/frames.js/src/types.ts b/packages/frames.js/src/types.ts
index d87bfa96e..e15fb6f91 100644
--- a/packages/frames.js/src/types.ts
+++ b/packages/frames.js/src/types.ts
@@ -1,4 +1,5 @@
import type { Abi, Hex, Address, TypedData } from "viem";
+import type { Frame as FrameV2 } from "./farcaster-v2/types";
export type {
ParsingReport,
@@ -34,16 +35,21 @@ export type Frame = {
title?: string;
};
+export { type FrameV2 };
+
export type ActionButtonType = "post" | "post_redirect" | "link";
+type FrameJSOptionalStringKeys =
+ | "frames.js:version"
+ | "frames.js:debug-info:image";
+
type FrameOptionalStringKeys =
| "fc:frame:image:aspect_ratio"
| "fc:frame:input:text"
| "fc:frame:state"
| "fc:frame:post_url"
| keyof OpenFramesProperties
- | "frames.js:version"
- | "frames.js:debug-info:image";
+ | FrameJSOptionalStringKeys;
type FrameOptionalActionButtonTypeKeys = `fc:frame:button:${
| 1
| 2
@@ -62,8 +68,8 @@ type MapFrameOptionalKeyToValueType =
K extends FrameOptionalStringKeys
? string | undefined
: K extends FrameOptionalActionButtonTypeKeys
- ? ActionButtonType | undefined
- : string | undefined;
+ ? ActionButtonType | undefined
+ : string | undefined;
type FrameRequiredProperties = {
"fc:frame": FrameVersion;
@@ -98,6 +104,12 @@ export type FrameFlattened = FrameRequiredProperties & {
| FrameOptionalButtonStringKeys]?: MapFrameOptionalKeyToValueType;
};
+export type FrameV2Flattened = {
+ "fc:frame": string;
+} & {
+ [K in FrameJSOptionalStringKeys]?: MapFrameOptionalKeyToValueType;
+};
+
export interface FrameButtonLink {
action: "link";
/** required for action type 'link' */
@@ -151,7 +163,7 @@ export type ActionIndex = 1 | 2 | 3 | 4;
export type FrameButtonsType = FrameButton[];
export type AddressReturnType<
- Options extends { fallbackToCustodyAddress?: boolean } | undefined
+ Options extends { fallbackToCustodyAddress?: boolean } | undefined,
> = Options extends { fallbackToCustodyAddress: true }
? `0x${string}`
: `0x${string}` | null;
diff --git a/packages/render/package.json b/packages/render/package.json
index efe34f3cc..f67daaca6 100644
--- a/packages/render/package.json
+++ b/packages/render/package.json
@@ -86,6 +86,46 @@
"default": "./dist/farcaster/index.cjs"
}
},
+ "./frame-app/iframe": {
+ "import": {
+ "types": "./dist/frame-app/iframe.d.ts",
+ "default": "./dist/frame-app/iframe.js"
+ },
+ "require": {
+ "types": "./dist/frame-app/iframe.d.cts",
+ "default": "./dist/frame-app/iframe.cjs"
+ }
+ },
+ "./frame-app/types": {
+ "import": {
+ "types": "./dist/frame-app/types.d.ts",
+ "default": "./dist/frame-app/types.js"
+ },
+ "require": {
+ "types": "./dist/frame-app/types.d.cts",
+ "default": "./dist/frame-app/types.cjs"
+ }
+ },
+ "./frame-app/web-view": {
+ "import": {
+ "types": "./dist/frame-app/web-view.d.ts",
+ "default": "./dist/frame-app/web-view.js"
+ },
+ "require": {
+ "types": "./dist/frame-app/web-view.d.cts",
+ "default": "./dist/frame-app/web-view.cjs"
+ }
+ },
+ "./frame-app/provider/wagmi": {
+ "import": {
+ "types": "./dist/frame-app/provider/wagmi.d.ts",
+ "default": "./dist/frame-app/provider/wagmi.js"
+ },
+ "require": {
+ "types": "./dist/frame-app/provider/wagmi.d.cts",
+ "default": "./dist/frame-app/provider/wagmi.cjs"
+ }
+ },
"./ui": {
"react-native": {
"types": "./dist/ui/index.native.d.cts",
@@ -100,6 +140,26 @@
"default": "./dist/ui/index.cjs"
}
},
+ "./ui/utils": {
+ "import": {
+ "types": "./dist/ui/utils.d.ts",
+ "default": "./dist/ui/utils.js"
+ },
+ "require": {
+ "types": "./dist/ui/utils.d.cts",
+ "default": "./dist/ui/utils.cjs"
+ }
+ },
+ "./ui/types": {
+ "import": {
+ "types": "./dist/ui/types.d.ts",
+ "default": "./dist/ui/types.js"
+ },
+ "require": {
+ "types": "./dist/ui/types.d.cts",
+ "default": "./dist/ui/types.cjs"
+ }
+ },
"./use-fetch-frame": {
"import": {
"types": "./dist/use-fetch-frame.d.ts",
@@ -130,24 +190,24 @@
"default": "./dist/use-frame.cjs"
}
},
- "./use-composer-action": {
+ "./use-cast-action": {
"import": {
- "types": "./dist/use-composer-action.d.ts",
- "default": "./dist/use-composer-action.js"
+ "types": "./dist/use-cast-action.d.ts",
+ "default": "./dist/use-cast-action.js"
},
"require": {
- "types": "./dist/use-composer-action.d.cts",
- "default": "./dist/use-composer-action.cjs"
+ "types": "./dist/use-cast-action.d.cts",
+ "default": "./dist/use-cast-action.cjs"
}
},
- "./unstable-use-frame-state": {
+ "./use-composer-action": {
"import": {
- "types": "./dist/unstable-use-frame-state.d.ts",
- "default": "./dist/unstable-use-frame-state.js"
+ "types": "./dist/use-composer-action.d.ts",
+ "default": "./dist/use-composer-action.js"
},
"require": {
- "types": "./dist/unstable-use-frame-state.d.cts",
- "default": "./dist/unstable-use-frame-state.cjs"
+ "types": "./dist/use-composer-action.d.cts",
+ "default": "./dist/use-composer-action.cjs"
}
},
"./unstable-use-fetch-frame": {
@@ -160,6 +220,26 @@
"default": "./dist/unstable-use-fetch-frame.cjs"
}
},
+ "./use-frame-app": {
+ "import": {
+ "types": "./dist/use-frame-app.d.ts",
+ "default": "./dist/use-frame-app.js"
+ },
+ "require": {
+ "types": "./dist/use-frame-app.d.cts",
+ "default": "./dist/use-frame-app.cjs"
+ }
+ },
+ "./unstable-use-frame-state": {
+ "import": {
+ "types": "./dist/unstable-use-frame-state.d.ts",
+ "default": "./dist/unstable-use-frame-state.js"
+ },
+ "require": {
+ "types": "./dist/unstable-use-frame-state.d.cts",
+ "default": "./dist/unstable-use-frame-state.cjs"
+ }
+ },
"./unstable-use-frame": {
"import": {
"types": "./dist/unstable-use-frame.d.ts",
@@ -256,6 +336,7 @@
"src"
],
"devDependencies": {
+ "@farcaster/frame-host-react-native": "^0.0.14",
"@lens-protocol/client": "^2.3.2",
"@rainbow-me/rainbowkit": "^2.1.2",
"@remix-run/node": "^2.8.1",
@@ -263,6 +344,7 @@
"@types/react": "^18.2.0",
"@xmtp/xmtp-js": "^12.0.0",
"@xmtp/frames-client": "^0.5.3",
+ "react-native-webview": "^13.12.4",
"tsup": "^8.0.1",
"typescript": "^5.4.5",
"viem": "^2.13.7",
@@ -270,6 +352,7 @@
},
"license": "MIT",
"peerDependencies": {
+ "@farcaster/frame-host-react-native": "^0.0.14",
"@lens-protocol/client": "^2.0.0",
"@rainbow-me/rainbowkit": "^2.1.2",
"@types/react": "^18.2.0",
@@ -279,13 +362,16 @@
"next": "^14.1.0",
"react": "^18.2.0",
"react-native": "^0.74.3",
+ "react-native-webview": "^13.12.4",
"viem": "^2.7.8",
"wagmi": "^2.9.10"
},
"dependencies": {
- "@farcaster/core": "^0.14.7",
+ "@farcaster/core": "^0.15.6",
+ "@farcaster/frame-host": "^0.0.19",
"@noble/ed25519": "^2.0.0",
"frames.js": "^0.20.0",
+ "ox": "^0.4.0",
"zod": "^3.23.8"
}
-}
\ No newline at end of file
+}
diff --git a/packages/render/src/assert-never.ts b/packages/render/src/assert-never.ts
new file mode 100644
index 000000000..3a0063395
--- /dev/null
+++ b/packages/render/src/assert-never.ts
@@ -0,0 +1,3 @@
+export function assertNever(x: never): never {
+ throw new Error(`Unhandled value ${JSON.stringify(x)}`);
+}
diff --git a/packages/render/src/collapsed-frame-ui.tsx b/packages/render/src/collapsed-frame-ui.tsx
index 1360497a0..1beaa42fa 100644
--- a/packages/render/src/collapsed-frame-ui.tsx
+++ b/packages/render/src/collapsed-frame-ui.tsx
@@ -3,6 +3,7 @@ import React, { useState } from "react";
import type { Frame } from "frames.js";
import type { FrameTheme, FrameState } from "./types";
import type { UseFrameReturnValue } from "./unstable-types";
+import { isValidPartialFrame } from "./ui/utils";
const defaultTheme: Required = {
buttonBg: "#fff",
@@ -21,13 +22,19 @@ const getThemeWithDefaults = (theme: FrameTheme): FrameTheme => {
};
export type CollapsedFrameUIProps = {
- frameState: FrameState | UseFrameReturnValue;
+ frameState:
+ | FrameState
+ | UseFrameReturnValue;
theme?: FrameTheme;
FrameImage?: React.FC & { src: string }>;
allowPartialFrame?: boolean;
};
-/** A UI component only, that should be easy for any app to integrate */
+/**
+ * A UI component only, that should be easy for any app to integrate.
+ *
+ * This component doesn't support Frames v2.
+ */
export function CollapsedFrameUI({
frameState,
theme,
@@ -50,12 +57,7 @@ export function CollapsedFrameUI({
if (
currentFrame.status === "done" &&
currentFrame.frameResult.status === "failure" &&
- !(
- allowPartialFrame &&
- // Need at least image and buttons to render a partial frame
- currentFrame.frameResult.frame.image &&
- currentFrame.frameResult.frame.buttons
- )
+ !(allowPartialFrame && isValidPartialFrame(currentFrame.frameResult))
) {
return null;
}
@@ -63,6 +65,11 @@ export function CollapsedFrameUI({
let frame: Frame | Partial | undefined;
if (currentFrame.status === "done") {
+ if (currentFrame.frameResult.specification === "farcaster_v2") {
+ // Do not render farcaster frames v2 as collapsed because they don't have such UI
+ return null;
+ }
+
frame = currentFrame.frameResult.frame;
} else if (
currentFrame.status === "message" ||
diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx
index 64f3ec037..895f9fa14 100644
--- a/packages/render/src/farcaster/frames.tsx
+++ b/packages/render/src/farcaster/frames.tsx
@@ -16,7 +16,9 @@ import type {
SignFrameActionFunc,
} from "../types";
import type {
- SignComposerActionFunc,
+ SignCastActionFunction,
+ SignComposerActionFunction,
+ SignerStateCastActionContext,
SignerStateComposerActionContext,
} from "../unstable-types";
import { tryCallAsync } from "../helpers";
@@ -26,7 +28,7 @@ import type { FarcasterFrameContext } from "./types";
/**
* Creates a singer request payload to fetch composer action url.
*/
-export const signComposerAction: SignComposerActionFunc =
+export const signComposerAction: SignComposerActionFunction =
async function signComposerAction(signerPrivateKey, actionContext) {
const messageOrError = await tryCallAsync(() =>
createComposerActionMessageWithSignerKey(signerPrivateKey, actionContext)
@@ -40,13 +42,40 @@ export const signComposerAction: SignComposerActionFunc =
return {
untrustedData: {
- buttonIndex: 1,
- fid: actionContext.fid,
+ buttonIndex: message.data.frameActionBody.buttonIndex,
+ fid: message.data.fid,
messageHash: bytesToHex(message.hash),
- network: 1,
+ network: FarcasterNetwork.MAINNET,
state: Buffer.from(message.data.frameActionBody.state).toString(),
- timestamp: new Date().getTime(),
- url: actionContext.url,
+ timestamp: message.data.timestamp,
+ url: Buffer.from(message.data.frameActionBody.url).toString(),
+ },
+ trustedData: {
+ messageBytes: trustedBytes,
+ },
+ };
+ };
+
+export const signCastAction: SignCastActionFunction =
+ async function signCastAction(signerPrivateKey, actionContext) {
+ const messageOrError = await tryCallAsync(() =>
+ createCastActionMessageWithSignerKey(signerPrivateKey, actionContext)
+ );
+
+ if (messageOrError instanceof Error) {
+ throw messageOrError;
+ }
+
+ const { message, trustedBytes } = messageOrError;
+
+ return {
+ untrustedData: {
+ buttonIndex: message.data.frameActionBody.buttonIndex,
+ fid: message.data.fid,
+ messageHash: bytesToHex(message.hash),
+ network: FarcasterNetwork.MAINNET,
+ timestamp: message.data.timestamp,
+ url: Buffer.from(message.data.frameActionBody.url).toString(),
},
trustedData: {
messageBytes: trustedBytes,
@@ -177,6 +206,46 @@ export async function createComposerActionMessageWithSignerKey(
return { message: messageData, trustedBytes };
}
+export async function createCastActionMessageWithSignerKey(
+ signerKey: string,
+ { fid, castId, postUrl }: SignerStateCastActionContext
+): Promise<{
+ message: FrameActionMessage;
+ trustedBytes: string;
+}> {
+ const signer = new NobleEd25519Signer(Buffer.from(signerKey.slice(2), "hex"));
+
+ const messageDataOptions = {
+ fid,
+ network: FarcasterNetwork.MAINNET,
+ };
+
+ const message = await makeFrameAction(
+ FrameActionBody.create({
+ url: Buffer.from(postUrl),
+ buttonIndex: 1,
+ castId: {
+ fid: castId.fid,
+ hash: hexToBytes(castId.hash),
+ },
+ }),
+ messageDataOptions,
+ signer
+ );
+
+ if (message.isErr()) {
+ throw message.error;
+ }
+
+ const messageData = message.value;
+
+ const trustedBytes = Buffer.from(
+ Message.encode(message._unsafeUnwrap()).finish()
+ ).toString("hex");
+
+ return { message: messageData, trustedBytes };
+}
+
export async function createFrameActionMessageWithSignerKey(
signerKey: string,
{
diff --git a/packages/render/src/farcaster/signers.tsx b/packages/render/src/farcaster/signers.tsx
index 58fe6b2a6..14cb6b167 100644
--- a/packages/render/src/farcaster/signers.tsx
+++ b/packages/render/src/farcaster/signers.tsx
@@ -1,5 +1,8 @@
import type { FrameActionBodyPayload, SignerStateInstance } from "../types";
-import type { SignComposerActionFunc } from "../unstable-types";
+import type {
+ SignCastActionFunction,
+ SignComposerActionFunction,
+} from "../unstable-types";
import type { FarcasterFrameContext } from "./types";
export type FarcasterSignerState =
@@ -8,7 +11,8 @@ export type FarcasterSignerState =
FrameActionBodyPayload,
FarcasterFrameContext
> & {
- signComposerAction: SignComposerActionFunc;
+ signComposerAction: SignComposerActionFunction;
+ signCastAction: SignCastActionFunction;
};
export type FarcasterSignerPendingApproval = {
diff --git a/packages/render/src/frame-app/iframe.ts b/packages/render/src/frame-app/iframe.ts
new file mode 100644
index 000000000..5060c1e4e
--- /dev/null
+++ b/packages/render/src/frame-app/iframe.ts
@@ -0,0 +1,186 @@
+import {
+ exposeToEndpoint,
+ createIframeEndpoint,
+ type HostEndpoint,
+} from "@farcaster/frame-host";
+import { useEffect, useMemo, useRef } from "react";
+import type { UseFrameAppOptions, UseFrameAppReturn } from "../use-frame-app";
+import { useFrameApp } from "../use-frame-app";
+import { useFreshRef } from "../hooks/use-fresh-ref";
+import { useDebugLog } from "../hooks/use-debug-log";
+import { assertNever } from "../assert-never";
+import type { HostEndpointEmitter } from "./types";
+
+type UseFrameAppReturnSuccess = Extract<
+ UseFrameAppReturn,
+ { status: "success" }
+>;
+
+export type UseFrameAppInIframeReturn =
+ | Exclude
+ | ({
+ iframeProps: {
+ src: string | undefined;
+ ref: React.MutableRefObject;
+ };
+ emitter: HostEndpointEmitter;
+ } & Omit);
+
+/**
+ * Handles frame app in iframe.
+ *
+ * On unmount it automatically unregisters the endpoint listener.
+ *
+ * @example
+ * ```
+ * import { useFrameAppInIframe } from '@frames.js/render/frame-app/iframe';
+ * import { useWagmiProvider } from '@frames.js/render/frame-app/provider/wagmi';
+ * import { useFarcasterSigner } from '@frames.js/render/identity/farcaster';
+ *
+ * function MyAppDialog() {
+ * const provider = useWagmiProvider();
+ * const farcasterSigner = useFarcasterSigner({
+ * // ...
+ * });
+ * const frameApp = useFrameAppInIframe({
+ * provider,
+ * farcasterSigner,
+ * // frame returned by useFrame() hook
+ * frame: frameState.frame,
+ * // ... handlers for frame app actions
+ * });
+ *
+ * if (frameApp.status !== 'success') {
+ * // render loading or error
+ * return null;
+ * }
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useFrameAppInIframe(
+ options: UseFrameAppOptions
+): UseFrameAppInIframeReturn {
+ const providerRef = useFreshRef(options.provider);
+ const debugRef = useFreshRef(options.debug ?? false);
+ const frameApp = useFrameApp(options);
+ const iframeRef = useRef(null);
+ const endpointRef = useRef(null);
+ const emitterRef = useRef(null);
+ const logDebug = useDebugLog(
+ "@frames.js/render/frame-app/iframe",
+ debugRef.current
+ );
+ const emitter = useMemo(() => {
+ return {
+ emit(...args) {
+ if (emitterRef.current) {
+ emitterRef.current.emit(...args);
+ } else {
+ logDebug(
+ "endpoint not available, probably not initialized yet, skipping emit",
+ args
+ );
+ }
+ },
+ emitEthProvider(...args) {
+ if (emitterRef.current) {
+ emitterRef.current.emitEthProvider(
+ ...(args as Parameters)
+ );
+ } else {
+ logDebug(
+ "endpoint not available, probably not initialized yet, skipping emitEthProvider",
+ args
+ );
+ }
+ },
+ };
+ }, [logDebug]);
+
+ const iframeFrameApp = useMemo(() => {
+ switch (frameApp.status) {
+ case "error":
+ case "pending":
+ return frameApp;
+ case "success": {
+ const frameUrl = frameApp.frame.frame.button?.action?.url;
+
+ if (!frameUrl) {
+ return {
+ status: "error",
+ error: new Error(
+ "Frame URL is not provided, please check button.action.url"
+ ),
+ };
+ }
+
+ const frameOrigin = new URL(frameUrl).origin;
+
+ return {
+ ...frameApp,
+ status: "success",
+ frameOrigin,
+ iframeProps: {
+ src: frameUrl,
+ ref: iframeRef,
+ },
+ emitter,
+ };
+ }
+ default:
+ assertNever(frameApp);
+ }
+ }, [frameApp, emitter]);
+
+ useEffect(() => {
+ if (iframeFrameApp.status !== "success") {
+ return;
+ }
+
+ const iframe = iframeRef.current;
+
+ if (!iframe) {
+ logDebug("iframe ref not available");
+ return;
+ }
+
+ const frameUrl = iframeFrameApp.iframeProps.src;
+ let frameOrigin = "";
+
+ if (!frameUrl) {
+ logDebug("frame URL not available, using empty string");
+ } else {
+ frameOrigin = new URL(frameUrl).origin;
+ }
+
+ const endpoint = createIframeEndpoint({
+ iframe,
+ targetOrigin: frameOrigin,
+ debug: debugRef.current,
+ });
+ const cleanup = exposeToEndpoint({
+ endpoint,
+ frameOrigin,
+ sdk: iframeFrameApp.sdk(endpoint),
+ debug: debugRef.current,
+ ethProvider: providerRef.current,
+ });
+
+ endpointRef.current = endpoint;
+ emitterRef.current = iframeFrameApp.getEmitter(endpoint);
+
+ logDebug("iframe endpoint created");
+
+ return () => {
+ logDebug("iframe unmounted, cleaning up");
+ endpointRef.current = null;
+ iframeRef.current = null;
+ emitterRef.current = null;
+ cleanup();
+ };
+ }, [iframeFrameApp, logDebug, debugRef, providerRef]);
+
+ return iframeFrameApp;
+}
diff --git a/packages/render/src/frame-app/provider/helpers.ts b/packages/render/src/frame-app/provider/helpers.ts
new file mode 100644
index 000000000..b5ad0907e
--- /dev/null
+++ b/packages/render/src/frame-app/provider/helpers.ts
@@ -0,0 +1,40 @@
+import type { EthProviderRequest } from "@farcaster/frame-host";
+import type { EIP1193Provider } from "viem";
+import type {
+ SendTransactionRpcRequest,
+ SignMessageRpcRequest,
+ SignTypedDataRpcRequest,
+} from "../types";
+
+export function isSendTransactionRpcRequest(
+ request: Parameters[0]
+): request is SendTransactionRpcRequest {
+ return request.method === "eth_sendTransaction";
+}
+
+export function isSignMessageRpcRequest(
+ request: Parameters[0]
+): request is SignMessageRpcRequest {
+ return request.method === "personal_sign";
+}
+
+export function isSignTypedDataRpcRequest(
+ request: Parameters[0]
+): request is SignTypedDataRpcRequest {
+ return request.method === "eth_signTypedData_v4";
+}
+
+export function isEIP1193Provider(
+ provider: unknown
+): provider is EIP1193Provider {
+ return (
+ typeof provider === "object" &&
+ provider !== null &&
+ "request" in provider &&
+ typeof provider.request === "function" &&
+ "on" in provider &&
+ typeof provider.on === "function" &&
+ "removeListener" in provider &&
+ typeof provider.removeListener === "function"
+ );
+}
diff --git a/packages/render/src/frame-app/provider/wagmi.ts b/packages/render/src/frame-app/provider/wagmi.ts
new file mode 100644
index 000000000..7a957b0e4
--- /dev/null
+++ b/packages/render/src/frame-app/provider/wagmi.ts
@@ -0,0 +1,172 @@
+import {
+ createEmitter,
+ type EventMap,
+ from,
+ type Emitter,
+ UserRejectedRequestError,
+} from "ox/Provider";
+import { useEffect, useRef } from "react";
+import { useAccount } from "wagmi";
+import { useFreshRef } from "../../hooks/use-fresh-ref";
+import { useDebugLog } from "../../hooks/use-debug-log";
+import type {
+ EthProvider,
+ OnSendTransactionRequestFunction,
+ OnSignMessageRequestFunction,
+ OnSignTypedDataRequestFunction,
+} from "../types";
+import {
+ isEIP1193Provider,
+ isSendTransactionRpcRequest,
+ isSignMessageRpcRequest,
+ isSignTypedDataRpcRequest,
+} from "./helpers";
+
+type UseWagmiProviderOptions = {
+ /**
+ * Enables debug logging
+ *
+ * @defaultValue false
+ */
+ debug?: boolean;
+};
+
+export function useWagmiProvider({
+ debug = false,
+}: UseWagmiProviderOptions = {}): EthProvider {
+ const logDebug = useDebugLog(
+ "@frames.js/render/frame-app/provider/wagmi",
+ debug
+ );
+ const account = useAccount();
+ const accountRef = useFreshRef(account);
+ const emitterRef = useRef(null);
+ const providerRef = useRef(null);
+ const onSendTransactionRequestRef =
+ useRef(null);
+ const onSignMessageRequestRef = useRef(
+ null
+ );
+ const onSignTypedDataRequestRef =
+ useRef(null);
+
+ if (!providerRef.current) {
+ emitterRef.current = createEmitter();
+ providerRef.current = {
+ ...from({
+ ...emitterRef.current,
+ async request(parameters) {
+ const connector = accountRef.current.connector;
+
+ if (!connector) {
+ throw new Error(
+ "No connector found, make sure you have wallet connected."
+ );
+ }
+
+ const provider = await connector.getProvider();
+
+ if (!isEIP1193Provider(provider)) {
+ throw new Error(
+ "Provider is not EIP-1193 compatible, make sure you have wallet connected."
+ );
+ }
+
+ logDebug("sdk.ethProviderRequest() called", parameters);
+
+ let isApproved = true;
+
+ if (isSendTransactionRpcRequest(parameters)) {
+ logDebug("sendTransaction request", parameters);
+ isApproved = onSendTransactionRequestRef.current
+ ? await onSendTransactionRequestRef.current(parameters)
+ : true;
+ } else if (isSignTypedDataRpcRequest(parameters)) {
+ logDebug("signTypedData request", parameters);
+ isApproved = onSignTypedDataRequestRef.current
+ ? await onSignTypedDataRequestRef.current(parameters)
+ : true;
+ } else if (isSignMessageRpcRequest(parameters)) {
+ logDebug("signMessage request", parameters);
+ isApproved = onSignMessageRequestRef.current
+ ? await onSignMessageRequestRef.current(parameters)
+ : true;
+ }
+
+ if (!isApproved) {
+ throw new UserRejectedRequestError(
+ new Error("User rejected request")
+ );
+ }
+
+ return provider.request(
+ parameters as unknown as Parameters[0]
+ );
+ },
+ }),
+ setEventHandlers(handlers) {
+ onSendTransactionRequestRef.current = handlers.onSendTransactionRequest;
+ onSignMessageRequestRef.current = handlers.onSignMessageRequest;
+ onSignTypedDataRequestRef.current = handlers.onSignTypedDataRequest;
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (account.status !== "connected") {
+ return;
+ }
+
+ const connector = account.connector;
+ let cleanup = (): void => {
+ // noop
+ };
+
+ // forward events to the provider
+ void connector.getProvider().then((provider) => {
+ if (!isEIP1193Provider(provider)) {
+ return;
+ }
+
+ if (!emitterRef.current) {
+ return;
+ }
+
+ const accountsChanged: EventMap["accountsChanged"] = (...args) => {
+ emitterRef.current?.emit("accountsChanged", ...args);
+ };
+ const chainChanged: EventMap["chainChanged"] = (...args) => {
+ emitterRef.current?.emit("chainChanged", ...args);
+ };
+ const connect: EventMap["connect"] = (...args) => {
+ emitterRef.current?.emit("connect", ...args);
+ };
+ const disconnect: EventMap["disconnect"] = (...args) => {
+ emitterRef.current?.emit("disconnect", ...args);
+ };
+ const message: EventMap["message"] = (...args) => {
+ emitterRef.current?.emit("message", ...args);
+ };
+
+ provider.on("accountsChanged", accountsChanged);
+ provider.on("chainChanged", chainChanged);
+ provider.on("connect", connect);
+ provider.on("disconnect", disconnect);
+ provider.on("message", message);
+
+ cleanup = () => {
+ provider.removeListener("accountsChanged", accountsChanged);
+ provider.removeListener("chainChanged", chainChanged);
+ provider.removeListener("connect", connect);
+ provider.removeListener("disconnect", disconnect);
+ provider.removeListener("message", message);
+ };
+ });
+
+ return () => {
+ cleanup();
+ };
+ }, [account]);
+
+ return providerRef.current;
+}
diff --git a/packages/render/src/frame-app/types.ts b/packages/render/src/frame-app/types.ts
new file mode 100644
index 000000000..500f0b585
--- /dev/null
+++ b/packages/render/src/frame-app/types.ts
@@ -0,0 +1,86 @@
+import type { HostEndpoint } from "@farcaster/frame-host";
+import type {
+ AddFrameResult,
+ FrameContext,
+ SetPrimaryButton,
+} from "@farcaster/frame-sdk";
+import type { ParseFramesV2ResultWithFrameworkDetails } from "frames.js/frame-parsers";
+import type { Provider } from "ox/Provider";
+import type { Default as DefaultRpcSchema, ExtractRequest } from "ox/RpcSchema";
+
+export type FrameClientConfig = FrameContext["client"];
+
+export type SendTransactionRpcRequest = ExtractRequest<
+ DefaultRpcSchema,
+ "eth_sendTransaction"
+>;
+
+export type SignMessageRpcRequest = ExtractRequest<
+ DefaultRpcSchema,
+ "personal_sign"
+>;
+
+export type SignTypedDataRpcRequest = ExtractRequest<
+ DefaultRpcSchema,
+ "eth_signTypedData_v4"
+>;
+
+export type EthProvider = Provider & {
+ setEventHandlers: (handlers: SharedEthProviderEventHandlers) => void;
+};
+
+/**
+ * Function called when the frame app requests sending transaction.
+ *
+ * If false is returned then the request is cancelled and user rejected error is thrown
+ */
+export type OnSendTransactionRequestFunction = (
+ request: SendTransactionRpcRequest
+) => Promise;
+
+/**
+ * Function called when the frame app requests signing message.
+ *
+ * If false is returned signing is cancelled and user rejected error is thrown
+ */
+export type OnSignMessageRequestFunction = (
+ request: SignMessageRpcRequest
+) => Promise;
+
+/**
+ * Function called when the frame app requests signing typed data.
+ *
+ * If false is returned then the request is cancelled and user rejected error is thrown
+ */
+export type OnSignTypedDataRequestFunction = (
+ request: SignTypedDataRpcRequest
+) => Promise;
+
+export type SharedEthProviderEventHandlers = {
+ onSendTransactionRequest: OnSendTransactionRequestFunction;
+ onSignMessageRequest: OnSignMessageRequestFunction;
+ onSignTypedDataRequest: OnSignTypedDataRequestFunction;
+};
+
+export type FramePrimaryButton = Parameters[0];
+
+export type OnPrimaryButtonSetFunction = (
+ options: FramePrimaryButton,
+ pressedCallback: () => void
+) => void;
+
+export type OnAddFrameRequestedFunction = (
+ frame: ParseFramesV2ResultWithFrameworkDetails
+) => Promise>;
+
+/**
+ * Function called when the frame app is being loaded and we need to resolve the client that renders the frame app
+ */
+export type ResolveClientFunction = (options: {
+ signal: AbortSignal;
+}) => Promise;
+
+export type HostEndpointEmitter = Pick<
+ HostEndpoint,
+ "emit" | "emitEthProvider"
+>;
diff --git a/packages/render/src/frame-app/use-fetch-frame-app.ts b/packages/render/src/frame-app/use-fetch-frame-app.ts
new file mode 100644
index 000000000..0a1b7804c
--- /dev/null
+++ b/packages/render/src/frame-app/use-fetch-frame-app.ts
@@ -0,0 +1,140 @@
+import type { ParseFramesV2ResultWithFrameworkDetails } from "frames.js/frame-parsers";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useFreshRef } from "../hooks/use-fresh-ref";
+import { fetchProxied } from "../unstable-use-fetch-frame";
+import { isParseFramesWithReportsResult } from "../helpers";
+
+type State =
+ | {
+ status: "success";
+ frame: ParseFramesV2ResultWithFrameworkDetails;
+ source: string | URL | ParseFramesV2ResultWithFrameworkDetails;
+ }
+ | {
+ status: "error";
+ error: Error;
+ }
+ | {
+ status: "pending";
+ };
+
+type UseFetchFrameAppOptions = {
+ source: string | URL | ParseFramesV2ResultWithFrameworkDetails;
+ proxyUrl: string;
+ fetchFn?: typeof fetch;
+};
+
+type UseFetchFrameAppResult = State;
+
+const defaultFetchFunction: typeof fetch = (...args) => fetch(...args);
+
+export function useFetchFrameApp({
+ fetchFn,
+ source,
+ proxyUrl,
+}: UseFetchFrameAppOptions): UseFetchFrameAppResult {
+ const abortControllerRef = useRef(null);
+ const fetchRef = useFreshRef(fetchFn ?? defaultFetchFunction);
+ const [state, setState] = useState(() => {
+ if (typeof source === "string" || source instanceof URL) {
+ return {
+ status: "pending",
+ };
+ }
+
+ return {
+ status: "success",
+ frame: source,
+ source,
+ };
+ });
+
+ const fetchFrame = useCallback(
+ (sourceUrl: string | URL) => {
+ // cancel previous request
+ abortControllerRef.current?.abort();
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ // we don't want to return promise from fetchFrame because it is used in effect
+ Promise.resolve(sourceUrl)
+ .then(async (url) => {
+ setState({
+ status: "pending",
+ });
+
+ const responseOrError = await fetchProxied({
+ fetchFn: fetchRef.current,
+ url: url.toString(),
+ parseFarcasterManifest: true,
+ proxyUrl,
+ signal: abortController.signal,
+ });
+
+ if (responseOrError instanceof Error) {
+ throw responseOrError;
+ }
+
+ if (!responseOrError.ok) {
+ throw new Error(
+ `Failed to fetch frame, server returned status ${responseOrError.status}`
+ );
+ }
+
+ const data = (await responseOrError.json()) as Promise;
+
+ if (!isParseFramesWithReportsResult(data)) {
+ throw new Error(
+ "Invalid response, expected parse result, make sure you are using @frames.js/render proxy"
+ );
+ }
+
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ setState({
+ status: "success",
+ frame: data.farcaster_v2,
+ source: sourceUrl,
+ });
+ })
+ .catch((e) => {
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ setState({
+ status: "error",
+ error: e instanceof Error ? e : new Error(String(e)),
+ });
+ });
+
+ return () => {
+ abortController.abort();
+ };
+ },
+ [fetchRef, proxyUrl]
+ );
+
+ useEffect(() => {
+ if (typeof source === "string" || source instanceof URL) {
+ return fetchFrame(source);
+ }
+
+ setState((val) => {
+ if (val.status === "success" && val.source === source) {
+ return val;
+ }
+
+ return {
+ status: "success",
+ frame: source,
+ source,
+ };
+ });
+ }, [source, fetchFrame]);
+
+ return state;
+}
diff --git a/packages/render/src/frame-app/use-resolve-client.ts b/packages/render/src/frame-app/use-resolve-client.ts
new file mode 100644
index 000000000..66da11576
--- /dev/null
+++ b/packages/render/src/frame-app/use-resolve-client.ts
@@ -0,0 +1,97 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { FrameClientConfig, ResolveClientFunction } from "./types";
+
+type UseResolveClientOptions = {
+ client: FrameClientConfig | ResolveClientFunction;
+};
+
+type UseResolveClientResult =
+ | {
+ status: "success";
+ client: FrameClientConfig;
+ }
+ | {
+ status: "error";
+ error: Error;
+ }
+ | {
+ status: "pending";
+ };
+
+export function useResolveClient({
+ client,
+}: UseResolveClientOptions): UseResolveClientResult {
+ const abortControllerRef = useRef(null);
+ const [state, setState] = useState(() => {
+ if (typeof client !== "function") {
+ return {
+ status: "success",
+ client,
+ };
+ }
+
+ return {
+ status: "pending",
+ };
+ });
+
+ const resolveClient = useCallback((resolve: ResolveClientFunction) => {
+ // cancel previous request
+ abortControllerRef.current?.abort();
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ Promise.resolve()
+ .then(async () => {
+ setState({
+ status: "pending",
+ });
+ const resolvedClient = await resolve({
+ signal: abortController.signal,
+ });
+
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ setState({
+ status: "success",
+ client: resolvedClient,
+ });
+ })
+ .catch((e) => {
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ setState({
+ status: "error",
+ error: e instanceof Error ? e : new Error(String(e)),
+ });
+ });
+
+ return () => {
+ abortController.abort();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (typeof client !== "function") {
+ setState((prevState) => {
+ if (prevState.status === "success" && prevState.client !== client) {
+ return {
+ status: "success",
+ client,
+ };
+ }
+
+ return prevState;
+ });
+ } else {
+ resolveClient(client);
+ }
+ }, [client, resolveClient]);
+
+ return state;
+}
diff --git a/packages/render/src/frame-app/web-view.ts b/packages/render/src/frame-app/web-view.ts
new file mode 100644
index 000000000..46e1ce79e
--- /dev/null
+++ b/packages/render/src/frame-app/web-view.ts
@@ -0,0 +1,186 @@
+import { exposeToEndpoint } from "@farcaster/frame-host";
+import {
+ createWebViewRpcEndpoint,
+ type WebViewEndpoint,
+} from "@farcaster/frame-host-react-native";
+import type { WebView, WebViewProps } from "react-native-webview";
+import type { LegacyRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ useFrameApp,
+ type UseFrameAppOptions,
+ type UseFrameAppReturn,
+} from "../use-frame-app";
+import { useFreshRef } from "../hooks/use-fresh-ref";
+import { useDebugLog } from "../hooks/use-debug-log";
+import { assertNever } from "../assert-never";
+import type { HostEndpointEmitter } from "./types";
+
+type UseFrameAppReturnSuccess = Extract<
+ UseFrameAppReturn,
+ { status: "success" }
+>;
+
+export type UseFrameAppInWebViewReturn =
+ | Exclude
+ | (Omit & {
+ emitter: HostEndpointEmitter;
+ webViewProps: {
+ source: WebViewProps["source"];
+ onMessage: NonNullable;
+ ref: LegacyRef;
+ };
+ });
+
+/**
+ * useFrameApp() hook for react-native-webview handling
+ *
+ * On unmount it automatically unregisters the endpoint listener.
+ *
+ * @example
+ * ```
+ * import { useFrameAppInWebView } from '@frames.js/render/frame-app/web-view';
+ * import { useWagmiProvider } from '@frames.js/render/frame-app/provider/wagmi';
+ * import { useFarcasterSigner } from '@frames.js/render/identity/farcaster';
+ *
+ * function MyAppDialog() {
+ * const provider = useWagmiProvider();
+ * const farcasterSigner = useFarcasterSigner({
+ * // ...
+ * });
+ * const frameApp = useFrameAppInWebView({
+ * provider,
+ * farcasterSigner,
+ * // frame returned by useFrame() hook
+ * frame: frameState.frame,
+ * // ... handlers for frame app actions
+ * });
+ *
+ * if (frameApp.status !== 'success') {
+ * // render loading or error
+ * return null;
+ * }
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useFrameAppInWebView(
+ options: UseFrameAppOptions
+): UseFrameAppInWebViewReturn {
+ const providerRef = useFreshRef(options.provider);
+ const webViewRef = useRef(null);
+ const debugRef = useFreshRef(options.debug ?? false);
+ const frameApp = useFrameApp(options);
+ const endpointRef = useRef(null);
+ const emitterRef = useRef(null);
+ const logDebug = useDebugLog(
+ "@frames.js/render/frame-app/web-view",
+ debugRef.current
+ );
+ const emitter = useMemo(() => {
+ return {
+ emit(...args) {
+ if (emitterRef.current) {
+ emitterRef.current.emit(...args);
+ } else {
+ logDebug(
+ "endpoint not available, probably not initialized yet, skipping emit",
+ args
+ );
+ }
+ },
+ emitEthProvider(...args) {
+ if (emitterRef.current) {
+ emitterRef.current.emitEthProvider(
+ ...(args as Parameters)
+ );
+ } else {
+ logDebug(
+ "endpoint not available, probably not initialized yet, skipping emitEthProvider",
+ args
+ );
+ }
+ },
+ };
+ }, [logDebug]);
+
+ const onMessage = useCallback>(
+ (event) => {
+ logDebug("webView.onMessage() called", event);
+
+ endpointRef.current?.onMessage(event);
+ },
+ [logDebug]
+ );
+
+ const webViewFrameApp = useMemo(() => {
+ switch (frameApp.status) {
+ case "error":
+ case "pending":
+ return frameApp;
+ case "success": {
+ const frame = frameApp.frame.frame;
+ const frameUrl = frame.button?.action?.url;
+
+ if (!frameUrl) {
+ return {
+ status: "error",
+ error: new Error(
+ "Frame URL is not provided, please check button.action.url"
+ ),
+ };
+ }
+
+ return {
+ ...frameApp,
+ emitter,
+ webViewProps: {
+ source: { uri: frameUrl },
+ onMessage,
+ ref: webViewRef,
+ },
+ };
+ }
+ default:
+ assertNever(frameApp);
+ }
+ }, [frameApp, onMessage, emitter]);
+
+ useEffect(() => {
+ if (webViewFrameApp.status !== "success") {
+ return;
+ }
+
+ const webView = webViewRef.current;
+
+ if (!webView) {
+ logDebug("WebView ref not available");
+ return;
+ }
+
+ const endpoint = createWebViewRpcEndpoint(webViewRef);
+ const cleanup = exposeToEndpoint({
+ endpoint,
+ frameOrigin: "ReactNativeWebView",
+ sdk: webViewFrameApp.sdk(endpoint),
+ debug: debugRef.current,
+ ethProvider: providerRef.current,
+ });
+
+ endpointRef.current = endpoint;
+ emitterRef.current = webViewFrameApp.getEmitter(endpoint);
+
+ logDebug("WebView endpoint created");
+
+ return () => {
+ logDebug("WebView unmounted, cleaning up");
+ webViewRef.current = null;
+ endpointRef.current = null;
+ emitterRef.current = null;
+ cleanup();
+ };
+ }, [webViewFrameApp, logDebug, debugRef, providerRef]);
+
+ return webViewFrameApp;
+}
diff --git a/packages/render/src/frame-ui.tsx b/packages/render/src/frame-ui.tsx
index b13e09d44..bc793c1ba 100644
--- a/packages/render/src/frame-ui.tsx
+++ b/packages/render/src/frame-ui.tsx
@@ -7,6 +7,7 @@ import type {
FrameStackMessage,
FrameStackRequestError,
} from "./types";
+import { isValidPartialFrame } from "./ui/utils";
export const defaultTheme: Required = {
buttonBg: "#fff",
@@ -117,7 +118,13 @@ export type FrameUIProps = {
enableImageDebugging?: boolean;
};
-/** A UI component only, that should be easy for any app to integrate */
+/**
+ * A UI component only, that should be easy for any app to integrate.
+ *
+ * This component doesn't support Frames V2.
+ *
+ * @deprecated - please use `FrameUI` from `@frames.js/render/ui` instead.
+ */
export function FrameUI({
frameState,
theme,
@@ -143,12 +150,7 @@ export function FrameUI({
if (
currentFrame.status === "done" &&
currentFrame.frameResult.status === "failure" &&
- !(
- allowPartialFrame &&
- // Need at least image and buttons to render a partial frame
- currentFrame.frameResult.frame.image &&
- currentFrame.frameResult.frame.buttons
- )
+ !(allowPartialFrame && isValidPartialFrame(currentFrame.frameResult))
) {
return ;
}
@@ -157,6 +159,14 @@ export function FrameUI({
let debugImage: string | undefined;
if (currentFrame.status === "done") {
+ // we don't support frames v2 in this component as it is deprecated
+ if (
+ currentFrame.frameResult.specification !== "farcaster" &&
+ currentFrame.frameResult.specification !== "openframes"
+ ) {
+ return null;
+ }
+
frame = currentFrame.frameResult.frame;
debugImage = enableImageDebugging
? currentFrame.frameResult.framesDebugInfo?.image
diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts
index c18382b23..4869c8305 100644
--- a/packages/render/src/helpers.ts
+++ b/packages/render/src/helpers.ts
@@ -1,8 +1,13 @@
import type {
ParseFramesWithReportsResult,
ParseResult,
+ ParseResultFramesV1Failure,
} from "frames.js/frame-parsers";
-import type { ComposerActionFormResponse } from "frames.js/types";
+import type {
+ CastActionFrameResponse,
+ CastActionMessageResponse,
+ ComposerActionFormResponse,
+} from "frames.js/types";
import type { PartialFrame } from "./ui/types";
export async function tryCallAsync(
@@ -55,7 +60,7 @@ export function isParseResult(value: unknown): value is ParseResult {
}
export type ParseResultWithPartialFrame = Omit<
- Exclude,
+ ParseResultFramesV1Failure,
"frame"
> & {
frame: PartialFrame;
@@ -84,6 +89,32 @@ export function isComposerFormActionResponse(
);
}
+export function isCastActionFrameResponse(
+ response: unknown
+): response is CastActionFrameResponse {
+ return (
+ typeof response === "object" &&
+ response !== null &&
+ "type" in response &&
+ response.type === "frame" &&
+ "frameUrl" in response &&
+ typeof response.frameUrl === "string"
+ );
+}
+
+export function isCastActionMessageResponse(
+ response: unknown
+): response is CastActionMessageResponse {
+ return (
+ typeof response === "object" &&
+ response !== null &&
+ "type" in response &&
+ response.type === "message" &&
+ "message" in response &&
+ typeof response.message === "string"
+ );
+}
+
/**
* Merges all search params in order from left to right into the URL.
*
diff --git a/packages/render/src/hooks/use-debug-log.ts b/packages/render/src/hooks/use-debug-log.ts
new file mode 100644
index 000000000..24b040a7d
--- /dev/null
+++ b/packages/render/src/hooks/use-debug-log.ts
@@ -0,0 +1,22 @@
+import { useCallback } from "react";
+import { useFreshRef } from "./use-fresh-ref";
+
+export function useDebugLog(
+ prefix: string,
+ enabled: boolean
+): typeof console.debug {
+ const enabledRef = useFreshRef(enabled);
+ const prefixRef = useFreshRef(prefix);
+
+ return useCallback(
+ (...args: Parameters) => {
+ if (!enabledRef.current) {
+ return;
+ }
+
+ // eslint-disable-next-line no-console -- provide feedback to the developer
+ console.debug(prefixRef.current, ...args);
+ },
+ [enabledRef, prefixRef]
+ );
+}
diff --git a/packages/render/src/identity/anonymous/use-anonymous-identity.tsx b/packages/render/src/identity/anonymous/use-anonymous-identity.tsx
index c1f1eb2eb..14fed933d 100644
--- a/packages/render/src/identity/anonymous/use-anonymous-identity.tsx
+++ b/packages/render/src/identity/anonymous/use-anonymous-identity.tsx
@@ -68,9 +68,12 @@ export function useAnonymousIdentity(): AnonymousSignerInstance {
isLoadingSigner: false,
logout,
signFrameAction,
- withContext(frameContext) {
+ withContext(frameContext, overrides) {
return {
- signerState: this,
+ signerState: {
+ ...this,
+ ...overrides,
+ },
frameContext,
};
},
diff --git a/packages/render/src/identity/farcaster/index.ts b/packages/render/src/identity/farcaster/index.ts
index c9cec3a42..b2eed0b66 100644
--- a/packages/render/src/identity/farcaster/index.ts
+++ b/packages/render/src/identity/farcaster/index.ts
@@ -1,4 +1,8 @@
-export { useFarcasterFrameContext } from "./use-farcaster-context";
+export {
+ type FarcasterFrameContext,
+ fallbackFrameContext,
+ useFarcasterFrameContext,
+} from "./use-farcaster-context";
export type { FarcasterSignedKeyRequest, FarcasterSigner } from "./types";
export {
type FarcasterSignerInstance,
diff --git a/packages/render/src/identity/farcaster/use-farcaster-context.tsx b/packages/render/src/identity/farcaster/use-farcaster-context.tsx
index f6abbb69c..8ac343b2c 100644
--- a/packages/render/src/identity/farcaster/use-farcaster-context.tsx
+++ b/packages/render/src/identity/farcaster/use-farcaster-context.tsx
@@ -1,6 +1,8 @@
import type { FarcasterFrameContext } from "../../farcaster/types";
import { createFrameContextHook } from "../create-frame-context-hook";
+export type { FarcasterFrameContext };
+
export const useFarcasterFrameContext =
createFrameContextHook({
storageKey: "farcasterFrameContext",
diff --git a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx
index e8d7c27e2..a853dfead 100644
--- a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx
+++ b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx
@@ -8,7 +8,11 @@ import {
} from "react";
import { convertKeypairToHex, createKeypairEDDSA } from "../crypto";
import type { FarcasterSignerState } from "../../farcaster";
-import { signComposerAction, signFrameAction } from "../../farcaster";
+import {
+ signComposerAction,
+ signCastAction,
+ signFrameAction,
+} from "../../farcaster";
import type { Storage } from "../types";
import { useVisibilityDetection } from "../../hooks/use-visibility-detection";
import { WebStorage } from "../storage";
@@ -205,6 +209,7 @@ export function useFarcasterIdentity({
const generateUserIdRef = useFreshRef(generateUserId);
const onMissingIdentityRef = useFreshRef(onMissingIdentity);
const fetchFnRef = useFreshRef(fetchFn);
+ const signerUrlRef = useFreshRef(signerUrl);
const createFarcasterSigner =
useCallback(async (): Promise => {
@@ -213,7 +218,7 @@ export function useFarcasterIdentity({
const keypairString = convertKeypairToHex(keypair);
const authorizationResponse = await fetchFnRef.current(
// real signer or local one are handled by local route so we don't need to expose anything to client side bundle
- signerUrl,
+ signerUrlRef.current,
{
method: "POST",
body: JSON.stringify({
@@ -311,7 +316,13 @@ export function useFarcasterIdentity({
console.error("@frames.js/render: API Call failed", error);
throw error;
}
- }, [fetchFnRef, generateUserIdRef, onLogInStartRef, setState, signerUrl]);
+ }, [
+ fetchFnRef,
+ generateUserIdRef,
+ onLogInStartRef,
+ setState,
+ signerUrlRef,
+ ]);
const impersonateUser = useCallback(
async (fid: number) => {
@@ -432,36 +443,67 @@ export function useFarcasterIdentity({
onLogInRef,
]);
- return useMemo(
- () => ({
- specification: "farcaster",
- signer: farcasterUser,
- hasSigner:
- farcasterUser?.status === "approved" ||
- farcasterUser?.status === "impersonating",
+ const farcasterUserRef = useFreshRef(farcasterUser);
+ const isLoadingRef = useFreshRef(isLoading);
+
+ return useMemo(() => {
+ /**
+ * These are here only for backwards compatiblity so value is invalidate on change of these
+ * without the necessity to refactor the whole signer to have some sort of event handlers
+ * that will be able to react to changes (although that would be useful as well).
+ *
+ * We are using refs to fetch the current value in getters below so these are just to make eslint happy
+ * without the necessity to disable the check on hook dependencies.
+ *
+ * We have getters here because there is an edge case if you have identity hook with async storage
+ * and you resolve the signer in useFrame() before the signer internal values are resolved.
+ * That leads into an edge case when useFrame() behaves like you aren't signed in but you are because
+ * the return value of memo is copied.
+ */
+ void farcasterUser;
+ void isLoading;
+
+ return {
+ specification: ["farcaster", "farcaster_v2"],
+ get signer() {
+ return farcasterUserRef.current;
+ },
+ get hasSigner() {
+ return (
+ farcasterUserRef.current?.status === "approved" ||
+ farcasterUserRef.current?.status === "impersonating"
+ );
+ },
signFrameAction,
+ signCastAction,
signComposerAction,
- isLoadingSigner: isLoading,
+ get isLoadingSigner() {
+ return isLoadingRef.current;
+ },
impersonateUser,
onSignerlessFramePress,
createSigner,
logout,
identityPoller,
- withContext(frameContext) {
+ withContext(frameContext, overrides) {
return {
- signerState: this,
+ signerState: {
+ ...this,
+ ...overrides,
+ },
frameContext,
};
},
- }),
- [
- farcasterUser,
- identityPoller,
- impersonateUser,
- isLoading,
- logout,
- createSigner,
- onSignerlessFramePress,
- ]
- );
+ };
+ }, [
+ farcasterUser,
+ isLoading,
+ impersonateUser,
+ onSignerlessFramePress,
+ createSigner,
+ logout,
+ identityPoller,
+ farcasterUserRef,
+ isLoadingRef,
+ ]);
}
diff --git a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx
index f4e1eeb13..5e98bbb3a 100644
--- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx
+++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx
@@ -8,7 +8,11 @@ import {
} from "react";
import { convertKeypairToHex, createKeypairEDDSA } from "../crypto";
import type { FarcasterSignerState } from "../../farcaster";
-import { signComposerAction, signFrameAction } from "../../farcaster";
+import {
+ signComposerAction,
+ signCastAction,
+ signFrameAction,
+} from "../../farcaster";
import type { Storage } from "../types";
import { useVisibilityDetection } from "../../hooks/use-visibility-detection";
import { WebStorage } from "../storage";
@@ -278,6 +282,7 @@ export function useFarcasterMultiIdentity({
const generateUserIdRef = useFreshRef(generateUserId);
const onMissingIdentityRef = useFreshRef(onMissingIdentity);
const fetchFnRef = useFreshRef(fetchFn);
+ const signerUrlRef = useFreshRef(signerUrl);
const createFarcasterSigner =
useCallback(async (): Promise => {
@@ -286,7 +291,7 @@ export function useFarcasterMultiIdentity({
const keypairString = convertKeypairToHex(keypair);
const authorizationResponse = await fetchFnRef.current(
// real signer or local one are handled by local route so we don't need to expose anything to client side bundle
- signerUrl,
+ signerUrlRef.current,
{
method: "POST",
body: JSON.stringify({
@@ -396,7 +401,13 @@ export function useFarcasterMultiIdentity({
console.error("@frames.js/render: API Call failed", error);
throw error;
}
- }, [fetchFnRef, generateUserIdRef, onLogInStartRef, setState, signerUrl]);
+ }, [
+ fetchFnRef,
+ generateUserIdRef,
+ onLogInStartRef,
+ setState,
+ signerUrlRef,
+ ]);
const impersonateUser = useCallback(
async (fid: number) => {
@@ -514,7 +525,7 @@ export function useFarcasterMultiIdentity({
unregisterVisibilityChangeListener();
};
}
- }, [farcasterUser, identityPoller, visibilityDetector, setState]);
+ }, [farcasterUser, identityPoller, visibilityDetector, setState, onLogInRef]);
const selectIdentity = useCallback(
async (id: number | string) => {
@@ -531,45 +542,71 @@ export function useFarcasterMultiIdentity({
return newState;
});
},
- [setState]
+ [onIdentitySelectRef, setState]
);
- return useMemo(
- () => ({
- specification: "farcaster",
- signer: farcasterUser,
- hasSigner:
- farcasterUser?.status === "approved" ||
- farcasterUser?.status === "impersonating",
+ const farcasterUserRef = useFreshRef(farcasterUser);
+ const isLoadingRef = useFreshRef(isLoading);
+ const identitiesRef = useFreshRef(state.identities);
+
+ return useMemo(() => {
+ /**
+ * See the explanation in useFarcasterIdentity()
+ */
+ void farcasterUser;
+ void isLoading;
+ void state.identities;
+
+ return {
+ specification: ["farcaster", "farcaster_v2"],
+ get signer() {
+ return farcasterUserRef.current;
+ },
+ get hasSigner() {
+ return (
+ farcasterUserRef.current?.status === "approved" ||
+ farcasterUserRef.current?.status === "impersonating"
+ );
+ },
signFrameAction,
+ signCastAction,
signComposerAction,
- isLoadingSigner: isLoading,
+ get isLoadingSigner() {
+ return isLoadingRef.current;
+ },
impersonateUser,
onSignerlessFramePress,
createSigner,
logout,
removeIdentity,
- identities: state.identities,
+ get identities() {
+ return identitiesRef.current;
+ },
selectIdentity,
identityPoller,
- withContext(frameContext) {
+ withContext(frameContext, overrides) {
return {
- signerState: this,
+ signerState: {
+ ...this,
+ ...overrides,
+ },
frameContext,
};
},
- }),
- [
- farcasterUser,
- identityPoller,
- impersonateUser,
- isLoading,
- logout,
- createSigner,
- onSignerlessFramePress,
- removeIdentity,
- selectIdentity,
- state.identities,
- ]
- );
+ };
+ }, [
+ impersonateUser,
+ onSignerlessFramePress,
+ createSigner,
+ logout,
+ removeIdentity,
+ selectIdentity,
+ identityPoller,
+ farcasterUserRef,
+ farcasterUser,
+ isLoading,
+ isLoadingRef,
+ state.identities,
+ identitiesRef,
+ ]);
}
diff --git a/packages/render/src/identity/lens/index.ts b/packages/render/src/identity/lens/index.ts
index de1d08a97..2a05cc782 100644
--- a/packages/render/src/identity/lens/index.ts
+++ b/packages/render/src/identity/lens/index.ts
@@ -1,6 +1,7 @@
-export { useLensFrameContext } from "./use-lens-context";
+export { type LensFrameContext, useLensFrameContext } from "./use-lens-context";
export {
type LensProfile,
type LensSigner,
+ type LensSignerInstance,
useLensIdentity,
} from "./use-lens-identity";
diff --git a/packages/render/src/identity/lens/use-lens-identity.tsx b/packages/render/src/identity/lens/use-lens-identity.tsx
index 2efcf0703..b1806d930 100644
--- a/packages/render/src/identity/lens/use-lens-identity.tsx
+++ b/packages/render/src/identity/lens/use-lens-identity.tsx
@@ -1,7 +1,12 @@
import { useConnectModal } from "@rainbow-me/rainbowkit";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useAccount, useConfig, useConnections } from "wagmi";
-import { signMessage, signTypedData, switchChain } from "wagmi/actions";
+import {
+ useAccount,
+ useConnections,
+ useSignTypedData,
+ useSignMessage,
+ useSwitchChain,
+} from "wagmi";
import { LensClient, development, production } from "@lens-protocol/client";
import type {
SignerStateActionContext,
@@ -10,6 +15,7 @@ import type {
} from "../../types";
import type { Storage } from "../types";
import { WebStorage } from "../storage";
+import { useFreshRef } from "../../hooks/use-fresh-ref";
import type { LensFrameContext } from "./use-lens-context";
export type LensProfile = {
@@ -45,7 +51,7 @@ type LensFrameRequest = {
};
};
-type LensSignerInstance = SignerStateInstance<
+export type LensSignerInstance = SignerStateInstance<
LensSigner,
LensFrameRequest,
LensFrameContext
@@ -84,9 +90,14 @@ export function useLensIdentity({
const [showProfileSelector, setShowProfileSelector] = useState(false);
const [availableProfiles, setAvailableProfiles] = useState([]);
const connect = useConnectModal();
- const config = useConfig();
const { address } = useAccount();
const activeConnection = useConnections();
+ const lensSignerRef = useFreshRef(lensSigner);
+ const { switchChainAsync } = useSwitchChain();
+ const { signMessageAsync } = useSignMessage();
+ const { signTypedDataAsync } = useSignTypedData();
+ const storageKeyRef = useFreshRef(storageKey);
+ const addressRef = useFreshRef(address);
const lensClient = useRef(
new LensClient({
@@ -94,6 +105,8 @@ export function useLensIdentity({
})
).current;
+ const connectRef = useFreshRef(connect);
+
useEffect(() => {
storageRef.current
.get(storageKey)
@@ -109,22 +122,26 @@ export function useLensIdentity({
}, [storageKey]);
const logout = useCallback(async () => {
- await storageRef.current.delete(storageKey);
+ await storageRef.current.delete(storageKeyRef.current);
setLensSigner(null);
- }, [storageKey]);
+ }, [storageKeyRef]);
const handleSelectProfile = useCallback(
async (profile: LensProfile) => {
try {
- if (!address) {
+ const walletAddress = addressRef.current;
+
+ if (!walletAddress) {
throw new Error("No wallet connected");
}
+
setShowProfileSelector(false);
+
const { id, text } = await lensClient.authentication.generateChallenge({
- signedBy: address,
+ signedBy: walletAddress,
for: profile.id,
});
- const signature = await signMessage(config, {
+ const signature = await signMessageAsync({
message: {
raw:
typeof text === "string"
@@ -132,7 +149,9 @@ export function useLensIdentity({
: Buffer.from(text as Uint8Array),
},
});
+
await lensClient.authentication.authenticate({ id, signature });
+
const accessTokenResult =
await lensClient.authentication.getAccessToken();
const identityTokenResult =
@@ -151,12 +170,15 @@ export function useLensIdentity({
const signer: LensSigner = {
accessToken,
profileId,
- address,
+ address: walletAddress,
identityToken,
handle,
};
- await storageRef.current.set(storageKey, () => signer);
+ await storageRef.current.set(
+ storageKeyRef.current,
+ () => signer
+ );
setLensSigner(signer);
}
@@ -165,36 +187,48 @@ export function useLensIdentity({
console.error("@frames.js/render: Create Lens signer failed", error);
}
},
- [address, config, lensClient.authentication, lensClient.profile, storageKey]
+ [
+ addressRef,
+ lensClient.authentication,
+ lensClient.profile,
+ signMessageAsync,
+ storageKeyRef,
+ ]
);
const onSignerlessFramePress = useCallback(async () => {
try {
setIsLoading(true);
- if (!lensSigner) {
- if (!address) {
- connect.openConnectModal?.();
- return;
- }
- const managedProfiles = await lensClient.wallet.profilesManaged({
- for: address,
- });
- const profiles: LensProfile[] = managedProfiles.items.map((p) => ({
- id: p.id,
- handle: p.handle ? `${p.handle.localName}.lens` : undefined,
- }));
+ const signer = lensSignerRef.current;
+ const currentAddress = addressRef.current;
- if (!profiles[0]) {
- throw new Error("No Lens profiles managed by connected address");
- }
+ if (signer) {
+ return;
+ }
- if (managedProfiles.items.length > 1) {
- setAvailableProfiles(profiles);
- setShowProfileSelector(true);
- } else {
- await handleSelectProfile(profiles[0]);
- }
+ if (!currentAddress) {
+ connectRef.current.openConnectModal?.();
+ return;
+ }
+
+ const managedProfiles = await lensClient.wallet.profilesManaged({
+ for: currentAddress,
+ });
+ const profiles: LensProfile[] = managedProfiles.items.map((p) => ({
+ id: p.id,
+ handle: p.handle ? `${p.handle.localName}.lens` : undefined,
+ }));
+
+ if (!profiles[0]) {
+ throw new Error("No Lens profiles managed by connected address");
+ }
+
+ if (managedProfiles.items.length > 1) {
+ setAvailableProfiles(profiles);
+ setShowProfileSelector(true);
+ } else {
+ await handleSelectProfile(profiles[0]);
}
} catch (error) {
// eslint-disable-next-line no-console -- provide feedback
@@ -202,22 +236,34 @@ export function useLensIdentity({
} finally {
setIsLoading(false);
}
- }, [address, connect, handleSelectProfile, lensClient.wallet, lensSigner]);
+ }, [
+ addressRef,
+ connectRef,
+ handleSelectProfile,
+ lensClient.wallet,
+ lensSignerRef,
+ ]);
+
+ const activeConnectionRef = useFreshRef(activeConnection);
const signFrameAction: SignFrameActionFunction<
SignerStateActionContext,
LensFrameRequest
> = useCallback(
async (actionContext) => {
- if (!lensSigner) {
+ const signer = lensSignerRef.current;
+
+ if (!signer) {
throw new Error("No lens signer active");
}
+
const profileManagers = await lensClient.profile.managers({
- for: lensSigner.profileId,
+ for: signer.profileId,
});
const lensManagerEnabled = profileManagers.items.some(
(manager) => manager.isLensManager
);
+
if (lensManagerEnabled) {
const result = await lensClient.frames.signFrameAction({
url: actionContext.url,
@@ -226,7 +272,7 @@ export function useLensIdentity({
buttonIndex: actionContext.buttonIndex,
actionResponse:
actionContext.type === "tx-post" ? actionContext.transactionId : "",
- profileId: lensSigner.profileId,
+ profileId: signer.profileId,
pubId: actionContext.frameContext.pubId || "",
specVersion: "1.0.0",
});
@@ -249,7 +295,7 @@ export function useLensIdentity({
clientProtocol: "lens@1.0.0",
untrustedData: {
...result.value.signedTypedData.value,
- identityToken: lensSigner.identityToken,
+ identityToken: signer.identityToken,
unixTimestamp: Date.now(),
},
trustedData: {
@@ -267,17 +313,19 @@ export function useLensIdentity({
buttonIndex: actionContext.buttonIndex,
actionResponse:
actionContext.type === "tx-post" ? actionContext.transactionId : "",
- profileId: lensSigner.profileId,
+ profileId: signer.profileId,
pubId: actionContext.frameContext.pubId || "",
specVersion: "1.0.0",
deadline: Math.floor(Date.now() / 1000) + 86400, // 1 day
});
- if (activeConnection[0]?.chainId !== typedData.domain.chainId) {
- await switchChain(config, { chainId: typedData.domain.chainId });
+ if (
+ activeConnectionRef.current[0]?.chainId !== typedData.domain.chainId
+ ) {
+ await switchChainAsync({ chainId: typedData.domain.chainId });
}
- const signature = await signTypedData(config, {
+ const signature = await signTypedDataAsync({
domain: {
...typedData.domain,
verifyingContract: typedData.domain
@@ -301,7 +349,7 @@ export function useLensIdentity({
clientProtocol: "lens@1.0.0",
untrustedData: {
...typedData.value,
- identityToken: lensSigner.identityToken,
+ identityToken: signer.identityToken,
unixTimestamp: Date.now(),
},
trustedData: {
@@ -312,11 +360,12 @@ export function useLensIdentity({
};
},
[
- activeConnection,
- config,
+ activeConnectionRef,
lensClient.frames,
lensClient.profile,
- lensSigner,
+ lensSignerRef,
+ signTypedDataAsync,
+ switchChainAsync,
]
);
@@ -324,36 +373,59 @@ export function useLensIdentity({
setShowProfileSelector(false);
}, []);
- return useMemo(
- () => ({
+ const isLoadingRef = useFreshRef(isLoading);
+ const availableProfilesRef = useFreshRef(availableProfiles);
+
+ return useMemo(() => {
+ /**
+ * See the explanation in useFarcasterIdentity()
+ */
+ void lensSigner;
+ void isLoading;
+ void availableProfiles;
+
+ return {
specification: "openframes",
- signer: lensSigner,
- hasSigner: !!lensSigner?.accessToken,
+ get signer() {
+ return lensSignerRef.current;
+ },
+ get hasSigner() {
+ return !!lensSignerRef.current?.accessToken;
+ },
signFrameAction,
- isLoadingSigner: isLoading,
+ get isLoadingSigner() {
+ return isLoadingRef.current;
+ },
onSignerlessFramePress,
logout,
showProfileSelector,
closeProfileSelector,
- availableProfiles,
+ get availableProfiles() {
+ return availableProfilesRef.current;
+ },
handleSelectProfile,
- withContext(frameContext) {
+ withContext(frameContext, overrides) {
return {
- signerState: this,
+ signerState: {
+ ...this,
+ ...overrides,
+ },
frameContext,
};
},
- }),
- [
- availableProfiles,
- closeProfileSelector,
- handleSelectProfile,
- isLoading,
- lensSigner,
- logout,
- onSignerlessFramePress,
- showProfileSelector,
- signFrameAction,
- ]
- );
+ };
+ }, [
+ availableProfiles,
+ availableProfilesRef,
+ closeProfileSelector,
+ handleSelectProfile,
+ isLoading,
+ isLoadingRef,
+ lensSigner,
+ lensSignerRef,
+ logout,
+ onSignerlessFramePress,
+ showProfileSelector,
+ signFrameAction,
+ ]);
}
diff --git a/packages/render/src/identity/xmtp/index.ts b/packages/render/src/identity/xmtp/index.ts
index c77e67e8f..814f5a04b 100644
--- a/packages/render/src/identity/xmtp/index.ts
+++ b/packages/render/src/identity/xmtp/index.ts
@@ -1,2 +1,6 @@
export { type XmtpFrameContext, useXmtpFrameContext } from "./use-xmtp-context";
-export { type XmtpSigner, useXmtpIdentity } from "./use-xmtp-identity";
+export {
+ type XmtpSigner,
+ type XmtpSignerInstance,
+ useXmtpIdentity,
+} from "./use-xmtp-identity";
diff --git a/packages/render/src/identity/xmtp/use-xmtp-identity.tsx b/packages/render/src/identity/xmtp/use-xmtp-identity.tsx
index 7f0950648..910f8022a 100644
--- a/packages/render/src/identity/xmtp/use-xmtp-identity.tsx
+++ b/packages/render/src/identity/xmtp/use-xmtp-identity.tsx
@@ -3,8 +3,7 @@ import { type FramePostPayload, FramesClient } from "@xmtp/frames-client";
import { Client, type Signer } from "@xmtp/xmtp-js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { zeroAddress } from "viem";
-import { useAccount, useConfig } from "wagmi";
-import { getAccount, signMessage } from "wagmi/actions";
+import { useAccount, useSignMessage } from "wagmi";
import type { Storage } from "../types";
import type {
SignerStateActionContext,
@@ -12,6 +11,7 @@ import type {
SignFrameActionFunction,
} from "../../types";
import { WebStorage } from "../storage";
+import { useFreshRef } from "../../hooks/use-fresh-ref";
import type { XmtpFrameContext } from "./use-xmtp-context";
export type XmtpSigner = {
@@ -24,7 +24,7 @@ type XmtpStoredSigner = {
keys: string;
};
-type XmtpSignerInstance = SignerStateInstance<
+export type XmtpSignerInstance = SignerStateInstance<
XmtpSigner,
FramePostPayload,
XmtpFrameContext
@@ -51,9 +51,9 @@ export function useXmtpIdentity({
const [isLoading, setIsLoading] = useState(false);
const [xmtpSigner, setXmtpSigner] = useState(null);
const [xmtpClient, setXmtpClient] = useState(null);
- const config = useConfig();
const connect = useConnectModal();
const { address } = useAccount();
+ const { signMessageAsync } = useSignMessage();
const walletSigner: Signer | null = useMemo(
() =>
@@ -63,7 +63,7 @@ export function useXmtpIdentity({
return Promise.resolve(address);
},
signMessage(message) {
- return signMessage(config, {
+ return signMessageAsync({
message: {
raw:
typeof message === "string"
@@ -74,7 +74,7 @@ export function useXmtpIdentity({
},
}
: null,
- [address, config]
+ [address, signMessageAsync]
);
useEffect(() => {
@@ -115,17 +115,27 @@ export function useXmtpIdentity({
setXmtpSigner(null);
}, [storageKey]);
+ const walletSignerRef = useFreshRef(walletSigner);
+ const xmtpSignerRef = useFreshRef(xmtpSigner);
+ const connectRef = useFreshRef(connect);
+ const xmtpClientRef = useFreshRef(xmtpClient);
+ const addressRef = useFreshRef(address);
+ const storageKeyRef = useFreshRef(storageKey);
+
const onSignerlessFramePress = useCallback(async (): Promise => {
try {
+ const wallet = walletSignerRef.current;
+ const signer = xmtpSignerRef.current;
+
setIsLoading(true);
- if (!xmtpSigner) {
- if (!walletSigner) {
- connect.openConnectModal?.();
+ if (!signer) {
+ if (!wallet) {
+ connectRef.current.openConnectModal?.();
return;
}
- const keys = await Client.getKeys(walletSigner, {
+ const keys = await Client.getKeys(wallet, {
env: "dev",
skipContactPublishing: true,
persistConversations: false,
@@ -134,12 +144,15 @@ export function useXmtpIdentity({
privateKeyOverride: keys,
});
- const walletAddress = getAccount(config).address || zeroAddress;
+ const walletAddress = addressRef.current || zeroAddress;
- await storageRef.current.set(storageKey, () => ({
- walletAddress,
- keys: Buffer.from(keys).toString("hex"),
- }));
+ await storageRef.current.set(
+ storageKeyRef.current,
+ () => ({
+ walletAddress,
+ keys: Buffer.from(keys).toString("hex"),
+ })
+ );
setXmtpSigner({
keys,
@@ -153,22 +166,20 @@ export function useXmtpIdentity({
} finally {
setIsLoading(false);
}
- }, [config, walletSigner, xmtpSigner, connect, storageKey]);
+ }, [walletSignerRef, xmtpSignerRef, addressRef, storageKeyRef, connectRef]);
const signFrameAction: SignFrameActionFunction<
SignerStateActionContext,
FramePostPayload
> = useCallback(
async (actionContext) => {
- if (!xmtpClient) {
- throw new Error("No xmtp client");
- }
+ const client = xmtpClientRef.current;
- if (!address) {
- throw new Error("No address");
+ if (!client) {
+ throw new Error("No xmtp client");
}
- const framesClient = new FramesClient(xmtpClient);
+ const framesClient = new FramesClient(client);
const payload = await framesClient.signFrameAction({
frameUrl: actionContext.url,
inputText: actionContext.inputText,
@@ -205,25 +216,49 @@ export function useXmtpIdentity({
searchParams,
};
},
- [address, xmtpClient]
+ [xmtpClientRef]
);
- return useMemo(
- () => ({
+ const isLoadingRef = useFreshRef(isLoading);
+
+ return useMemo(() => {
+ /**
+ * See the explanation in useFarcasterIdentity()
+ */
+ void xmtpSigner;
+ void isLoading;
+
+ return {
specification: "openframes",
- signer: xmtpSigner,
- hasSigner: !!xmtpSigner?.keys,
+ get signer() {
+ return xmtpSignerRef.current;
+ },
+ get hasSigner() {
+ return !!xmtpSignerRef.current?.keys;
+ },
signFrameAction,
- isLoadingSigner: isLoading,
+ get isLoadingSigner() {
+ return isLoadingRef.current;
+ },
onSignerlessFramePress,
logout,
- withContext(frameContext) {
+ withContext(frameContext, overrides) {
return {
- signerState: this,
+ signerState: {
+ ...this,
+ ...overrides,
+ },
frameContext,
};
},
- }),
- [isLoading, logout, onSignerlessFramePress, signFrameAction, xmtpSigner]
- );
+ };
+ }, [
+ isLoading,
+ isLoadingRef,
+ logout,
+ onSignerlessFramePress,
+ signFrameAction,
+ xmtpSigner,
+ xmtpSignerRef,
+ ]);
}
diff --git a/packages/render/src/next/GET.tsx b/packages/render/src/next/GET.tsx
index 12c685251..3965b150c 100644
--- a/packages/render/src/next/GET.tsx
+++ b/packages/render/src/next/GET.tsx
@@ -18,6 +18,10 @@ export async function GET(request: Request | NextRequest): Promise {
: new URL(request.url).searchParams;
const url = searchParams.get("url");
const specification = searchParams.get("specification") ?? "farcaster";
+ const parseFarcasterManifest =
+ searchParams.get("parseFarcasterManifest") === "true";
+ const strictlyParseFarcasterV2 =
+ searchParams.get("looseFarcasterV2Parsing") !== "true";
const multiSpecificationEnabled =
searchParams.get("multispecification") === "true";
@@ -31,10 +35,19 @@ export async function GET(request: Request | NextRequest): Promise {
const html = await urlRes.text();
if (multiSpecificationEnabled) {
- const result: ParseFramesWithReportsResult = parseFramesWithReports({
- html,
- fallbackPostUrl: url,
- });
+ const result: ParseFramesWithReportsResult = await parseFramesWithReports(
+ {
+ html,
+ fallbackPostUrl: url,
+ frameUrl: url,
+ parseSettings: {
+ farcaster_v2: {
+ parseManifest: parseFarcasterManifest,
+ strict: strictlyParseFarcasterV2,
+ },
+ },
+ }
+ );
return Response.json(result satisfies GETResponse);
}
@@ -48,8 +61,9 @@ export async function GET(request: Request | NextRequest): Promise {
);
}
- const result = getFrame({
+ const result = await getFrame({
htmlString: html,
+ frameUrl: url,
url,
specification,
});
diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx
index e9f216bb0..b62e1570c 100644
--- a/packages/render/src/next/POST.tsx
+++ b/packages/render/src/next/POST.tsx
@@ -241,8 +241,9 @@ export async function POST(req: Request | NextRequest): Promise {
const html = await response.text();
if (multiSpecificationEnabled) {
- const result = parseFramesWithReports({
+ const result = await parseFramesWithReports({
html,
+ frameUrl: body.untrustedData.url,
fallbackPostUrl: body.untrustedData.url,
fromRequestMethod: "POST",
});
@@ -259,8 +260,9 @@ export async function POST(req: Request | NextRequest): Promise {
);
}
- const result = getFrame({
+ const result = await getFrame({
htmlString: html,
+ frameUrl: body.untrustedData.url,
url: body.untrustedData.url,
fromRequestMethod: "POST",
specification,
diff --git a/packages/render/src/next/validators.ts b/packages/render/src/next/validators.ts
index a395f454e..6c03ce5c3 100644
--- a/packages/render/src/next/validators.ts
+++ b/packages/render/src/next/validators.ts
@@ -5,6 +5,6 @@ export function isSpecificationValid(
): specification is SupportedParsingSpecification {
return (
typeof specification === "string" &&
- ["farcaster", "openframes"].includes(specification)
+ ["farcaster", "farcaster_v2", "openframes"].includes(specification)
);
}
diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts
index 6a524d703..dd798f736 100644
--- a/packages/render/src/types.ts
+++ b/packages/render/src/types.ts
@@ -260,7 +260,7 @@ export type UseFrameOptions<
*
* @defaultValue 'farcaster'
*/
- specification?: SupportedParsingSpecification;
+ specification?: Exclude;
/**
* This function can be used to customize how error is reported to the user.
*/
@@ -368,8 +368,13 @@ export interface SignerStateInstance<
> {
/**
* For which specification is this signer required.
+ *
+ * If the value is an array it will take first valid specification if there is no valid specification
+ * it will return the first specification in array no matter the validity.
*/
- readonly specification: SupportedParsingSpecification;
+ readonly specification:
+ | SupportedParsingSpecification
+ | SupportedParsingSpecification[];
signer: TSignerStorageType | null;
/**
* True only if signer is approved or impersonating
@@ -384,7 +389,14 @@ export interface SignerStateInstance<
/** A function called when a frame button is clicked without a signer */
onSignerlessFramePress: () => Promise;
logout: () => Promise;
- withContext: (context: TFrameContextType) => {
+ withContext: (
+ context: TFrameContextType,
+ overrides?: {
+ specification?:
+ | SupportedParsingSpecification
+ | SupportedParsingSpecification[];
+ }
+ ) => {
signerState: SignerStateInstance<
TSignerStorageType,
TFrameActionBodyType,
@@ -474,7 +486,7 @@ export type FrameStackGetPending = {
export type FrameStackPending = FrameStackGetPending | FrameStackPostPending;
-export type GetFrameResult = ReturnType;
+export type GetFrameResult = Awaited>;
export type FrameStackDone = FrameStackBase & {
request: FrameRequest;
@@ -539,7 +551,7 @@ export type FrameReducerActions =
action: "RESET_INITIAL_FRAME";
resultOrFrame: ParseResult | Frame;
homeframeUrl: string | null | undefined;
- specification: SupportedParsingSpecification;
+ specification: Exclude;
};
export type ButtonPressFunction<
diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx
index 91e1fd0ec..4c17f8548 100644
--- a/packages/render/src/ui/frame.base.tsx
+++ b/packages/render/src/ui/frame.base.tsx
@@ -8,6 +8,7 @@ import {
} from "react";
import type { FrameState } from "../types";
import type { UseFrameReturnValue } from "../unstable-types";
+import { useFreshRef } from "../hooks/use-fresh-ref";
import type {
FrameMessage,
FrameUIComponents as BaseFrameUIComponents,
@@ -15,6 +16,7 @@ import type {
FrameUIState,
RootContainerDimensions,
RootContainerElement,
+ FrameButtonProps,
} from "./types";
import {
getErrorMessageFromFramesStackItem,
@@ -66,14 +68,18 @@ export type BaseFrameUIProps> = {
// eslint-disable-next-line @typescript-eslint/no-empty-function -- this is noop
function defaultMessageHandler(): void {}
+function defaultErrorLogger(error: Error): void {
+ // eslint-disable-next-line no-console -- provide at least some feedback to the user
+ console.error(error);
+}
+
export function BaseFrameUI>({
frameState,
components,
theme,
allowPartialFrame = false,
enableImageDebugging = false,
- // eslint-disable-next-line no-console -- provide at least some feedback to the user
- onError = console.error,
+ onError = defaultErrorLogger,
onMessage = defaultMessageHandler,
createElement = reactCreateElement,
}: BaseFrameUIProps): JSX.Element | null {
@@ -81,6 +87,7 @@ export function BaseFrameUI>({
const { currentFrameStackItem } = frameState;
const rootRef = useRef(null);
const rootDimensionsRef = useRef