From 199d5350427cb0b532af51ec5b00bc4e1509b61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Thu, 24 Oct 2024 16:12:50 +0200 Subject: [PATCH 01/27] feat: parse all specs at once --- packages/frames.js/package.json | 10 +++++ .../src/frame-parsers/farcaster.test.ts | 18 ++++++++ .../frames.js/src/frame-parsers/farcaster.ts | 2 + .../src/frame-parsers/open-frames.test.ts | 27 ++++++++++++ .../src/frame-parsers/open-frames.ts | 2 + packages/frames.js/src/frame-parsers/types.ts | 19 ++++++++ packages/frames.js/src/getFrame.test.ts | 13 +++++- packages/frames.js/src/getFrame.ts | 20 ++------- .../src/parseFramesWithReports.test.ts | 8 ++++ .../frames.js/src/parseFramesWithReports.ts | 43 +++++++++---------- packages/frames.js/src/types.ts | 9 +--- packages/frames.js/src/utils.ts | 5 +-- 12 files changed, 125 insertions(+), 51 deletions(-) diff --git a/packages/frames.js/package.json b/packages/frames.js/package.json index 6121801f2..b7d824f0e 100644 --- a/packages/frames.js/package.json +++ b/packages/frames.js/package.json @@ -267,6 +267,16 @@ "default": "./dist/hono/index.cjs" } }, + "./parseFramesWithReports": { + "import": { + "types": "./dist/parseFramesWithReports.d.ts", + "default": "./dist/parseFramesWithReports.js" + }, + "require": { + "types": "./dist/parseFramesWithReports.d.cts", + "default": "./dist/parseFramesWithReports.cjs" + } + }, "./remix": { "import": { "types": "./dist/remix/index.d.ts", diff --git a/packages/frames.js/src/frame-parsers/farcaster.test.ts b/packages/frames.js/src/frame-parsers/farcaster.test.ts index 78e327847..826a1938a 100644 --- a/packages/frames.js/src/frame-parsers/farcaster.test.ts +++ b/packages/frames.js/src/frame-parsers/farcaster.test.ts @@ -23,6 +23,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", reports: {}, frame: { image: "http://example.com/image.png", @@ -46,6 +47,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toMatchObject({ status: "failure", + specification: "farcaster", frame: {}, reports: expect.objectContaining({ "fc:frame": [ @@ -71,6 +73,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toMatchObject({ status: "failure", + specification: "farcaster", frame: {}, reports: expect.objectContaining({ "fc:frame": [ @@ -96,6 +99,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", ogImage: "http://example.com/image.png", @@ -123,6 +127,7 @@ describe("farcaster frame parser", () => { }) ).toEqual({ status: "success", + specification: "farcaster", reports: { title: [ { @@ -153,6 +158,7 @@ describe("farcaster frame parser", () => { expect(parseFarcasterFrame($, { reporter, fallbackPostUrl })).toEqual({ status: "success", + specification: "farcaster", reports: {}, frame: { version: "vNext", @@ -174,6 +180,7 @@ describe("farcaster frame parser", () => { expect(parseFarcasterFrame($, { reporter, fallbackPostUrl })).toEqual({ status: "success", + specification: "farcaster", reports: {}, frame: { version: "vNext", @@ -196,6 +203,7 @@ describe("farcaster frame parser", () => { expect(parseFarcasterFrame($, { reporter, fallbackPostUrl })).toEqual({ status: "success", + specification: "farcaster", reports: { "og:image": [ { @@ -229,6 +237,7 @@ describe("farcaster frame parser", () => { }) ).toEqual({ status: "success", + specification: "farcaster", reports: {}, frame: { version: "vNext", @@ -249,6 +258,7 @@ describe("farcaster frame parser", () => { expect(parseFarcasterFrame($, { reporter, fallbackPostUrl })).toEqual({ status: "success", + specification: "farcaster", reports: {}, frame: { version: "vNext", @@ -273,6 +283,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toMatchObject({ status: "failure", + specification: "farcaster", frame: { version: "vNext", ogImage: "http://example.com/image.png", @@ -303,6 +314,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", ogImage: "http://example.com/image.png", @@ -329,6 +341,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", version: "vNext", @@ -356,6 +369,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", version: "vNext", @@ -384,6 +398,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toMatchObject({ status: "failure", + specification: "farcaster", frame: { version: "vNext", image: "http://example.com/image.png", @@ -416,6 +431,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", version: "vNext", @@ -442,6 +458,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", ogImage: "http://example.com/image.png", @@ -471,6 +488,7 @@ describe("farcaster frame parser", () => { parseFarcasterFrame(document, { reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "farcaster", frame: { image: "http://example.com/image.png", version: "vNext", diff --git a/packages/frames.js/src/frame-parsers/farcaster.ts b/packages/frames.js/src/frame-parsers/farcaster.ts index e2919fce4..830324150 100644 --- a/packages/frames.js/src/frame-parsers/farcaster.ts +++ b/packages/frames.js/src/frame-parsers/farcaster.ts @@ -143,6 +143,7 @@ export function parseFarcasterFrame( status: "failure", frame, reports: reporter.toObject(), + specification: "farcaster", }; } @@ -150,5 +151,6 @@ export function parseFarcasterFrame( status: "success", frame: frame as unknown as Frame, reports: reporter.toObject(), + specification: "farcaster", }; } diff --git a/packages/frames.js/src/frame-parsers/open-frames.test.ts b/packages/frames.js/src/frame-parsers/open-frames.test.ts index 92633c2cf..87cc171ba 100644 --- a/packages/frames.js/src/frame-parsers/open-frames.test.ts +++ b/packages/frames.js/src/frame-parsers/open-frames.test.ts @@ -24,6 +24,7 @@ describe("open frames frame parser", () => { parseOpenFramesFrame($, { farcasterFrame: {}, reporter, fallbackPostUrl }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -62,6 +63,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "myproto", version: "1.0.0" }], @@ -98,6 +100,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "failure", + specification: "openframes", reports: { "of:version": [ { @@ -133,6 +136,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "failure", + specification: "openframes", reports: { "of:version": [ { @@ -169,6 +173,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -199,6 +204,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "failure", + specification: "openframes", frame: { accepts: [], version: "vNext", @@ -237,6 +243,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -267,6 +274,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: { title: [ { @@ -305,6 +313,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -334,6 +343,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -364,6 +374,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: { "og:image": [ { @@ -400,6 +411,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -428,6 +440,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -458,6 +471,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "failure", + specification: "openframes", reports: { "of:image": [ { @@ -495,6 +509,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -524,6 +539,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -557,6 +573,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -588,6 +605,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -622,6 +640,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -653,6 +672,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", frame: { accepts: [{ id: "some_protocol", version: "vNext" }], version: "vNext", @@ -687,6 +707,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "failure", + specification: "openframes", reports: { "of:post_url": [ { @@ -726,6 +747,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -756,6 +778,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -785,6 +808,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -818,6 +842,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "farcaster", version: "vNext" }], @@ -849,6 +874,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], @@ -884,6 +910,7 @@ describe("open frames frame parser", () => { }) ).toEqual({ status: "success", + specification: "openframes", reports: {}, frame: { accepts: [{ id: "some_protocol", version: "vNext" }], diff --git a/packages/frames.js/src/frame-parsers/open-frames.ts b/packages/frames.js/src/frame-parsers/open-frames.ts index 9a04ecbbf..f589379cd 100644 --- a/packages/frames.js/src/frame-parsers/open-frames.ts +++ b/packages/frames.js/src/frame-parsers/open-frames.ts @@ -194,6 +194,7 @@ export function parseOpenFramesFrame( status: "failure", frame, reports: reporter.toObject(), + specification: "openframes", }; } @@ -201,5 +202,6 @@ export function parseOpenFramesFrame( status: "success", frame: frame as unknown as Frame, reports: reporter.toObject(), + specification: "openframes", }; } diff --git a/packages/frames.js/src/frame-parsers/types.ts b/packages/frames.js/src/frame-parsers/types.ts index b815105d5..f47886fae 100644 --- a/packages/frames.js/src/frame-parsers/types.ts +++ b/packages/frames.js/src/frame-parsers/types.ts @@ -51,6 +51,7 @@ export type ParseResult = * Reports contain only warnings that should not have any impact on the frame's functionality. */ reports: Record; + specification: SupportedParsingSpecification; } | { status: "failure"; @@ -59,4 +60,22 @@ export type ParseResult = * Reports contain warnings and errors that should be addressed before the frame can be used. */ reports: Record; + specification: SupportedParsingSpecification; }; + +export type ParsedFrameworkDetails = { + framesVersion?: string; + framesDebugInfo?: { + /** + * Image URL of debug image. + */ + image?: string; + }; +}; + +export type ParseResultWithFrameworkDetails = ParseResult & + ParsedFrameworkDetails; + +export type ParseFramesWithReportsResult = { + [K in SupportedParsingSpecification]: ParseResultWithFrameworkDetails; +}; diff --git a/packages/frames.js/src/getFrame.test.ts b/packages/frames.js/src/getFrame.test.ts index c19bfe1a5..411823aab 100644 --- a/packages/frames.js/src/getFrame.test.ts +++ b/packages/frames.js/src/getFrame.test.ts @@ -25,6 +25,8 @@ describe("getFrame", () => { }) ).toEqual({ status: "success", + framesVersion: undefined, + specification: "farcaster", frame: { version: "vNext", image: "http://example.com/image.png", @@ -83,6 +85,8 @@ describe("getFrame", () => { expect(frame).toEqual({ status: "success", + framesVersion: undefined, + specification: "farcaster", frame: { version: "vNext", image: "http://example.com/image.png", @@ -113,7 +117,6 @@ describe("getFrame", () => { title: "test", }, reports: {}, - framesVersion: undefined, }); }); @@ -162,6 +165,7 @@ describe("getFrame", () => { expect(parsedFrame).toEqual({ status: "success", + specification: "farcaster", frame: { ...exampleFrame, title: "Test", accepts: undefined }, reports: {}, framesVersion, @@ -192,6 +196,8 @@ describe("getFrame", () => { expect(frame).not.toBeNull(); expect(frame).toEqual({ status: "success", + framesVersion: undefined, + specification: "openframes", frame: { accepts: [{ id: "some", version: "vNext" }], version: "vNext", @@ -279,6 +285,8 @@ describe("getFrame", () => { expect(parseResult).toEqual({ status: "success", + framesVersion: undefined, + specification: "farcaster", frame: { version: "vNext", image: "http://example.com/image.png", @@ -337,6 +345,8 @@ describe("getFrame", () => { expect(frame).toEqual({ status: "success", + framesVersion: undefined, + specification: "farcaster", frame: { version: "vNext", image: "http://example.com/image.png", @@ -353,7 +363,6 @@ describe("getFrame", () => { postUrl: "https://example.com/", }, reports: {}, - framesVersion: undefined, }); }); }); diff --git a/packages/frames.js/src/getFrame.ts b/packages/frames.js/src/getFrame.ts index 03383e31a..130c86c07 100644 --- a/packages/frames.js/src/getFrame.ts +++ b/packages/frames.js/src/getFrame.ts @@ -1,18 +1,10 @@ import type { - ParseResult, SupportedParsingSpecification, + ParseResultWithFrameworkDetails, } from "./frame-parsers/types"; import { parseFramesWithReports } from "./parseFramesWithReports"; -type GetFrameResult = ParseResult & { - framesVersion?: string; - framesDebugInfo?: { - /** - * Image URL of debug image. - */ - image?: string; - }; -}; +export type GetFrameResult = ParseResultWithFrameworkDetails; type GetFrameOptions = { htmlString: string; @@ -51,11 +43,5 @@ export function getFrame({ fromRequestMethod, }); - return { - ...parsedFrames[specification], - framesVersion: parsedFrames.framesVersion, - ...(parsedFrames.framesDebugInfo - ? { framesDebugInfo: parsedFrames.framesDebugInfo } - : {}), - }; + return parsedFrames[specification]; } diff --git a/packages/frames.js/src/parseFramesWithReports.test.ts b/packages/frames.js/src/parseFramesWithReports.test.ts index 9f6cbef7b..7a98deb29 100644 --- a/packages/frames.js/src/parseFramesWithReports.test.ts +++ b/packages/frames.js/src/parseFramesWithReports.test.ts @@ -30,6 +30,8 @@ describe("parseFramesWithReports", () => { }, reports: {}, status: "success", + specification: "farcaster", + framesVersion: undefined, }, openframes: { frame: { @@ -43,6 +45,8 @@ describe("parseFramesWithReports", () => { }, reports: {}, status: "success", + specification: "openframes", + framesVersion: undefined, }, }); }); @@ -78,6 +82,8 @@ describe("parseFramesWithReports", () => { title: "Test", }, reports: {}, + specification: "farcaster", + framesVersion: undefined, status: "success", }, openframes: { @@ -92,6 +98,8 @@ describe("parseFramesWithReports", () => { }, reports: {}, status: "success", + framesVersion: undefined, + specification: "openframes", }, }); }); diff --git a/packages/frames.js/src/parseFramesWithReports.ts b/packages/frames.js/src/parseFramesWithReports.ts index 26bee1e24..c9932a8d9 100644 --- a/packages/frames.js/src/parseFramesWithReports.ts +++ b/packages/frames.js/src/parseFramesWithReports.ts @@ -1,8 +1,8 @@ import { load as loadDocument } from "cheerio"; import { createReporter } from "./frame-parsers/reporter"; import type { - ParseResult, - SupportedParsingSpecification, + ParsedFrameworkDetails, + ParseFramesWithReportsResult, } from "./frame-parsers/types"; import { parseFarcasterFrame } from "./frame-parsers/farcaster"; import { parseOpenFramesFrame } from "./frame-parsers/open-frames"; @@ -24,18 +24,6 @@ type ParseFramesWithReportsOptions = { fromRequestMethod?: "GET" | "POST"; }; -export type ParseFramesWithReportsResult = { - [K in SupportedParsingSpecification]: ParseResult; -} & { - framesVersion?: string; - framesDebugInfo?: { - /** - * Image URL of debug image. - */ - image?: string; - }; -}; - /** * Gets all supported frames and validation their respective validation reports. */ @@ -62,14 +50,14 @@ export function parseFramesWithReports({ `meta[name="${FRAMESJS_DEBUG_INFO_IMAGE_KEY}"], meta[property="${FRAMESJS_DEBUG_INFO_IMAGE_KEY}"]` ).attr("content"); - return { - farcaster, - openframes: parseOpenFramesFrame(document, { - farcasterFrame: farcaster.frame, - reporter: openFramesReporter, - fallbackPostUrl, - fromRequestMethod, - }), + const openframes = parseOpenFramesFrame(document, { + farcasterFrame: farcaster.frame, + reporter: openFramesReporter, + fallbackPostUrl, + fromRequestMethod, + }); + + const frameworkDetails: ParsedFrameworkDetails = { framesVersion, ...(debugImageUrl ? { @@ -79,4 +67,15 @@ export function parseFramesWithReports({ } : {}), }; + + return { + farcaster: { + ...farcaster, + ...frameworkDetails, + }, + openframes: { + ...openframes, + ...frameworkDetails, + }, + }; } diff --git a/packages/frames.js/src/types.ts b/packages/frames.js/src/types.ts index b07bb075f..d87bfa96e 100644 --- a/packages/frames.js/src/types.ts +++ b/packages/frames.js/src/types.ts @@ -5,7 +5,7 @@ export type { SupportedParsingSpecification, } from "./frame-parsers/types"; -export type FrameVersion = "vNext" | `${number}-${number}-${number}`; +export type FrameVersion = string; export type ImageAspectRatio = "1.91:1" | "1:1"; @@ -148,12 +148,7 @@ export type FrameButton = /** The permitted types of `buttonIndex` in a Frame POST payload response */ export type ActionIndex = 1 | 2 | 3 | 4; -export type FrameButtonsType = - | [] - | [FrameButton] - | [FrameButton, FrameButton] - | [FrameButton, FrameButton, FrameButton] - | [FrameButton, FrameButton, FrameButton, FrameButton]; +export type FrameButtonsType = FrameButton[]; export type AddressReturnType< Options extends { fallbackToCustodyAddress?: boolean } | undefined diff --git a/packages/frames.js/src/utils.ts b/packages/frames.js/src/utils.ts index e962419a4..c05c7af13 100644 --- a/packages/frames.js/src/utils.ts +++ b/packages/frames.js/src/utils.ts @@ -6,7 +6,6 @@ import type { FrameButtonLink, FrameButtonMint, FrameButtonTx, - FrameVersion, } from "./types"; export function isFrameButtonLink( @@ -69,7 +68,7 @@ export function getFrameMessageFromRequestBody( * @param version - the version string to validate * @returns true if the provided version conforms to the Frames spec */ -export function isValidVersion(version: string): version is FrameVersion { +export function isValidVersion(version: string): boolean { // Check if the input is exactly 'vNext' if (version === "vNext") { return true; @@ -95,7 +94,7 @@ export function isValidVersion(version: string): version is FrameVersion { export function getEnumKeyByEnumValue< TEnumKey extends string, - TEnumVal extends string | number + TEnumVal extends string | number, >( enumDefinition: { [key in TEnumKey]: TEnumVal }, enumValue: TEnumVal From 0bbcc196a4e13bc209018da3dbbbe612f25b22b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Thu, 24 Oct 2024 16:23:16 +0200 Subject: [PATCH 02/27] feat: handle multi protocol result in render package --- packages/debugger/app/debugger-page.tsx | 8 +- packages/debugger/app/frames/route.ts | 44 +++-- packages/render/src/collapsed-frame-ui.tsx | 2 +- packages/render/src/fallback-frame-context.ts | 2 +- packages/render/src/farcaster/frames.tsx | 15 +- packages/render/src/farcaster/index.ts | 1 + packages/render/src/farcaster/signers.tsx | 9 +- packages/render/src/farcaster/types.ts | 5 + packages/render/src/helpers.ts | 13 ++ packages/render/src/hooks/use-fresh-ref.ts | 7 + .../farcaster/use-farcaster-context.tsx | 2 +- packages/render/src/next/GET.tsx | 45 +++-- packages/render/src/next/POST.tsx | 184 ++++++++++++++---- packages/render/src/next/validators.ts | 10 - packages/render/src/types.ts | 42 ++-- packages/render/src/ui/frame.base.tsx | 14 +- packages/render/src/ui/types.ts | 13 +- packages/render/src/use-fetch-frame.ts | 54 ++++- packages/render/src/use-frame-stack.ts | 10 +- packages/render/src/use-frame.tsx | 12 +- 20 files changed, 340 insertions(+), 152 deletions(-) create mode 100644 packages/render/src/farcaster/types.ts create mode 100644 packages/render/src/hooks/use-fresh-ref.ts delete mode 100644 packages/render/src/next/validators.ts diff --git a/packages/debugger/app/debugger-page.tsx b/packages/debugger/app/debugger-page.tsx index 90d1e3753..6b1b60bed 100644 --- a/packages/debugger/app/debugger-page.tsx +++ b/packages/debugger/app/debugger-page.tsx @@ -10,6 +10,7 @@ import { type OnSignatureFunc, type FrameActionBodyPayload, type OnConnectWalletFunc, + type FarcasterFrameContext, } from "@frames.js/render"; import { attribution } from "@frames.js/render/farcaster"; import { useFrame } from "@frames.js/render/use-frame"; @@ -174,7 +175,7 @@ export default function DebuggerPage({ const searchParams = new URLSearchParams({ url: newUrl || url, - specification: protocolConfiguration?.specification, + specification: protocolConfiguration.specification, actions: "true", }); const proxiedUrl = `/frames?${searchParams.toString()}`; @@ -196,7 +197,7 @@ export default function DebuggerPage({ setInitialAction(json); setInitialFrame(undefined); } else if (json.type === "frame") { - setInitialFrame(json); + setInitialFrame(json[protocolConfiguration.specification]); setInitialAction(undefined); } }) @@ -573,7 +574,8 @@ export default function DebuggerPage({ const farcasterFrameConfig: UseFrameOptions< FarcasterSigner | null, - FrameActionBodyPayload + FrameActionBodyPayload, + FarcasterFrameContext > = useMemo(() => { const attributionData = process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID ? attribution(parseInt(process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID)) diff --git a/packages/debugger/app/frames/route.ts b/packages/debugger/app/frames/route.ts index e7c4b9cce..9ec36cc26 100644 --- a/packages/debugger/app/frames/route.ts +++ b/packages/debugger/app/frames/route.ts @@ -1,11 +1,13 @@ -import { type FrameActionPayload, getFrame } from "frames.js"; +import { type FrameActionPayload } from "frames.js"; import { type NextRequest } from "next/server"; import { getAction } from "../actions/getAction"; import { persistMockResponsesForDebugHubRequests } from "../utils/mock-hub-utils"; import type { SupportedParsingSpecification } from "frames.js"; +import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import { z } from "zod"; import type { ParseActionResult } from "../actions/types"; -import type { ParseResult } from "frames.js/frame-parsers"; +import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; +import type { JsonObject } from "frames.js/types"; const castActionMessageParser = z.object({ type: z.literal("message"), @@ -43,7 +45,7 @@ export type CastActionDefinitionResponse = ParseActionResult & { url: string; }; -export type FrameDefinitionResponse = ParseResult & { +export type FrameDefinitionResponse = ParseFramesWithReportsResult & { type: "frame"; }; @@ -91,17 +93,16 @@ export async function GET(request: NextRequest): Promise { } satisfies CastActionDefinitionResponse); } - const htmlString = await urlRes.text(); + const html = await urlRes.text(); - const result = getFrame({ - htmlString, - url, - specification, + const parseResult = parseFramesWithReports({ + html, + fallbackPostUrl: url, fromRequestMethod: "GET", }); return Response.json({ - ...result, + ...parseResult, type: "frame", } satisfies FrameDefinitionResponse); } catch (err) { @@ -138,7 +139,7 @@ export async function POST(req: NextRequest): Promise { } if (!postUrl) { - return Response.error(); + return Response.json({ message: "Invalid post URL" }, { status: 400 }); } try { @@ -197,7 +198,7 @@ export async function POST(req: NextRequest): Promise { } if (isTransactionRequest) { - const transaction = (await r.json()) as JSON; + const transaction = (await r.json()) as JsonObject; return Response.json(transaction); } @@ -221,19 +222,26 @@ export async function POST(req: NextRequest): Promise { }); } - const htmlString = await r.text(); + const html = await r.text(); - const result = getFrame({ - htmlString, - url: body.untrustedData.url, - specification, + const parseResult = parseFramesWithReports({ + html, + fallbackPostUrl: body.untrustedData.url, fromRequestMethod: "POST", }); - return Response.json(result); + return Response.json({ + type: "frame", + ...parseResult, + } satisfies FrameDefinitionResponse); } catch (err) { // eslint-disable-next-line no-console -- provide feedback to the user console.error(err); - return Response.error(); + return Response.json( + { + message: String(err), + }, + { status: 500 } + ); } } diff --git a/packages/render/src/collapsed-frame-ui.tsx b/packages/render/src/collapsed-frame-ui.tsx index 661c8e6ca..bdd358ad8 100644 --- a/packages/render/src/collapsed-frame-ui.tsx +++ b/packages/render/src/collapsed-frame-ui.tsx @@ -138,7 +138,7 @@ export function CollapsedFrameUI({ cursor: isLoading ? undefined : "pointer", }} > - {frame.buttons.length === 1 && frame.buttons[0].label.length < 12 + {!!frame.buttons[0] && frame.buttons[0].label.length < 12 ? frame.buttons[0].label : "View"} diff --git a/packages/render/src/fallback-frame-context.ts b/packages/render/src/fallback-frame-context.ts index 8c5ceef3d..2c9f5b76a 100644 --- a/packages/render/src/fallback-frame-context.ts +++ b/packages/render/src/fallback-frame-context.ts @@ -1,4 +1,4 @@ -import type { FarcasterFrameContext } from "./farcaster"; +import type { FarcasterFrameContext } from "./farcaster/types"; export const fallbackFrameContext: FarcasterFrameContext = { castId: { diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx index 9341fc322..7ccb9a013 100644 --- a/packages/render/src/farcaster/frames.tsx +++ b/packages/render/src/farcaster/frames.tsx @@ -16,17 +16,14 @@ import type { SignFrameActionFunc, } from "../types"; import type { FarcasterSigner } from "./signers"; - -export type FarcasterFrameContext = { - /** Connected address of user, only sent with transaction data request */ - address?: `0x${string}`; - castId: { hash: `0x${string}`; fid: number }; -}; +import type { FarcasterFrameContext } from "./types"; /** Creates a frame action for use with `useFrame` and a proxy */ -export const signFrameAction: SignFrameActionFunc = async ( - actionContext -) => { +export const signFrameAction: SignFrameActionFunc< + FarcasterSigner, + FrameActionBodyPayload, + FarcasterFrameContext +> = async (actionContext) => { const { frameButton, signer, diff --git a/packages/render/src/farcaster/index.ts b/packages/render/src/farcaster/index.ts index 6c8b3f2b3..1816ee9fb 100644 --- a/packages/render/src/farcaster/index.ts +++ b/packages/render/src/farcaster/index.ts @@ -1,3 +1,4 @@ export * from "./frames"; export * from "./signers"; export * from "./attribution"; +export * from "./types"; diff --git a/packages/render/src/farcaster/signers.tsx b/packages/render/src/farcaster/signers.tsx index 4c26f5d4d..08cd75195 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,7 +1,12 @@ -import type { SignerStateInstance } from ".."; +import type { FrameActionBodyPayload, SignerStateInstance } from "../types"; +import type { FarcasterFrameContext } from "./types"; export type FarcasterSignerState = - SignerStateInstance; + SignerStateInstance< + TSignerType, + FrameActionBodyPayload, + FarcasterFrameContext + >; export type FarcasterSignerPendingApproval = { status: "pending_approval"; diff --git a/packages/render/src/farcaster/types.ts b/packages/render/src/farcaster/types.ts new file mode 100644 index 000000000..cce4d8425 --- /dev/null +++ b/packages/render/src/farcaster/types.ts @@ -0,0 +1,5 @@ +export type FarcasterFrameContext = { + /** Connected address of user, only sent with transaction data request */ + address?: `0x${string}`; + castId: { hash: `0x${string}`; fid: number }; +}; diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index c4b5b0c46..092a92a08 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -1,3 +1,5 @@ +import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; + export async function tryCallAsync( promiseFn: () => Promise ): Promise { @@ -25,3 +27,14 @@ export function tryCall(fn: () => TReturn): TReturn | Error { return new TypeError("Unexpected error, check the console for details"); } } + +export function isParseFramesWithReportsResult( + value: unknown +): value is ParseFramesWithReportsResult { + return ( + typeof value === "object" && + value !== null && + "openframes" in value && + "farcaster" in value + ); +} diff --git a/packages/render/src/hooks/use-fresh-ref.ts b/packages/render/src/hooks/use-fresh-ref.ts new file mode 100644 index 000000000..e1e2a5686 --- /dev/null +++ b/packages/render/src/hooks/use-fresh-ref.ts @@ -0,0 +1,7 @@ +import { useRef } from "react"; + +export function useFreshRef(value: T): React.MutableRefObject { + const ref = useRef(value); + ref.current = value; + return ref; +} diff --git a/packages/render/src/identity/farcaster/use-farcaster-context.tsx b/packages/render/src/identity/farcaster/use-farcaster-context.tsx index e7485c8ba..8f7707653 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-context.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-context.tsx @@ -1,4 +1,4 @@ -import type { FarcasterFrameContext } from "../../farcaster"; +import type { FarcasterFrameContext } from "../../farcaster/types"; import { createFrameContextHook } from "../create-frame-context-hook"; export const useFarcasterFrameContext = diff --git a/packages/render/src/next/GET.tsx b/packages/render/src/next/GET.tsx index ca1a600d3..e92761826 100644 --- a/packages/render/src/next/GET.tsx +++ b/packages/render/src/next/GET.tsx @@ -1,34 +1,37 @@ -import { getFrame } from "frames.js"; import type { NextRequest } from "next/server"; -import { isSpecificationValid } from "./validators"; +import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; +import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; + +export type GETResponse = ParseFramesWithReportsResult | { message: string }; /** Proxies fetching a frame through a backend to avoid CORS issues and preserve user IP privacy */ export async function GET(request: Request | NextRequest): Promise { - const searchParams = - "nextUrl" in request - ? request.nextUrl.searchParams - : new URL(request.url).searchParams; - const url = searchParams.get("url"); - const specification = searchParams.get("specification") ?? "farcaster"; + try { + const searchParams = + "nextUrl" in request + ? request.nextUrl.searchParams + : new URL(request.url).searchParams; + const url = searchParams.get("url"); - if (!url) { - return Response.json({ message: "Invalid URL" }, { status: 400 }); - } + if (!url) { + return Response.json({ message: "Invalid URL" } satisfies GETResponse, { + status: 400, + }); + } - if (!isSpecificationValid(specification)) { - return Response.json({ message: "Invalid specification" }, { status: 400 }); - } - - try { const urlRes = await fetch(url); - const htmlString = await urlRes.text(); - - const result = getFrame({ htmlString, url, specification }); + const html = await urlRes.text(); + const result: ParseFramesWithReportsResult = parseFramesWithReports({ + html, + fallbackPostUrl: url, + }); - return Response.json(result); + return Response.json(result satisfies GETResponse); } catch (err) { // eslint-disable-next-line no-console -- provide feedback to the developer console.error(err); - return Response.json({ message: err }, { status: 500 }); + return Response.json({ message: String(err) } satisfies GETResponse, { + status: 500, + }); } } diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index ac9f63ea7..b02167d36 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -1,28 +1,53 @@ import type { FrameActionPayload } from "frames.js"; -import { getFrame } from "frames.js"; +import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; +import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; +import type { JsonObject, JsonValue } from "frames.js/types"; import type { NextRequest } from "next/server"; -import { isSpecificationValid } from "./validators"; +import { tryCallAsync } from "../helpers"; + +export type POSTResponseError = { message: string }; + +export type POSTResponseRedirect = { location: string }; + +export type POSTTransactionResponse = JsonObject; + +export type POSTResponse = + | ParseFramesWithReportsResult + | POSTResponseError + | POSTResponseRedirect + | JsonObject; + +function isJsonErrorObject(data: JsonValue): data is { message: string } { + return ( + typeof data === "object" && + data !== null && + "message" in data && + typeof data.message === "string" + ); +} /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: Request | NextRequest): Promise { - const searchParams = - "nextUrl" in req ? req.nextUrl.searchParams : new URL(req.url).searchParams; - const body = (await req.json()) as FrameActionPayload; - const isPostRedirect = searchParams.get("postType") === "post_redirect"; - const isTransactionRequest = searchParams.get("postType") === "tx"; - const postUrl = searchParams.get("postUrl"); - const specification = searchParams.get("specification") ?? "farcaster"; - - if (!postUrl) { - return Response.error(); - } + try { + const searchParams = + "nextUrl" in req + ? req.nextUrl.searchParams + : new URL(req.url).searchParams; + const body = (await req.json()) as FrameActionPayload; + const isPostRedirect = searchParams.get("postType") === "post_redirect"; + const isTransactionRequest = searchParams.get("postType") === "tx"; + const postUrl = searchParams.get("postUrl"); - if (!isSpecificationValid(specification)) { - return Response.json({ message: "Invalid specification" }, { status: 400 }); - } + if (!postUrl) { + return Response.json( + { message: "postUrl parameter not found" } satisfies POSTResponseError, + { + status: 400, + } + ); + } - try { - const r = await fetch(postUrl, { + const response = await fetch(postUrl, { method: "POST", headers: { Accept: "application/json", @@ -32,53 +57,130 @@ export async function POST(req: Request | NextRequest): Promise { body: JSON.stringify(body), }); - if (r.status >= 500) { - return r; + if (response.status >= 500) { + const jsonError = await tryCallAsync( + () => response.clone().json() as Promise + ); + + if (jsonError instanceof Error) { + return Response.json( + { message: jsonError.message } satisfies POSTResponseError, + { status: response.status } + ); + } + + if (isJsonErrorObject(jsonError)) { + return Response.json( + { message: jsonError.message } satisfies POSTResponseError, + { status: response.status } + ); + } + + // eslint-disable-next-line no-console -- provide feedback to the user + console.error(jsonError); + + return Response.json( + { + message: `Frame server returned an unexpected error.`, + } satisfies POSTResponseError, + { status: 500 } + ); } - if (r.status === 302) { + if (response.status === 302) { + const location = response.headers.get("location"); + + if (!location) { + return Response.json( + { + message: + "Frame server returned a redirect without a location header", + } satisfies POSTResponseError, + { status: 500 } + ); + } + return Response.json( { - location: r.headers.get("location"), - }, + location, + } satisfies POSTResponseRedirect, { status: 302 } ); + } else if (isPostRedirect) { + return Response.json( + { + message: "Frame server did not return a 302 redirect", + } satisfies POSTResponseError, + { status: 500 } + ); } - if (r.status >= 400 && r.status < 500) { - const json = (await r.json()) as { message?: string }; + if (response.status >= 400 && response.status < 500) { + const jsonError = await tryCallAsync( + () => response.clone().json() as Promise + ); + + if (jsonError instanceof Error) { + return Response.json( + { message: jsonError.message } satisfies POSTResponseError, + { status: response.status } + ); + } - if ("message" in json) { - return Response.json({ message: json.message }, { status: r.status }); - } else { - return r; + if (isJsonErrorObject(jsonError)) { + return Response.json( + { message: jsonError.message } satisfies POSTResponseError, + { status: response.status } + ); } + + // eslint-disable-next-line no-console -- provide feedback to the user + console.error(jsonError); + + return Response.json( + { + message: `Frame server returned an unexpected error.`, + } satisfies POSTResponseError, + { status: response.status } + ); } - if (isPostRedirect && r.status !== 302) { + if (response.status !== 200) { return Response.json( - { message: "Invalid response for redirect button" }, + { + message: `Frame server returned a non-200 status code: ${response.status}`, + } satisfies POSTResponseError, { status: 500 } ); } if (isTransactionRequest) { - const transaction = (await r.json()) as JSON; - return Response.json(transaction); - } + const transaction = await tryCallAsync( + () => response.clone().json() as Promise + ); - const htmlString = await r.text(); + if (transaction instanceof Error) { + return Response.json( + { message: transaction.message } satisfies POSTResponseError, + { status: 500 } + ); + } - const result = getFrame({ - htmlString, - url: body.untrustedData.url, - specification, + return Response.json(transaction satisfies JsonObject); + } + + const html = await response.text(); + const result: ParseFramesWithReportsResult = parseFramesWithReports({ + html, + fallbackPostUrl: body.untrustedData.url, }); - return Response.json(result); + return Response.json(result satisfies POSTResponse); } catch (err) { // eslint-disable-next-line no-console -- provide feedback to the user console.error(err); - return Response.error(); + return Response.json({ message: String(err) } satisfies POSTResponseError, { + status: 500, + }); } } diff --git a/packages/render/src/next/validators.ts b/packages/render/src/next/validators.ts deleted file mode 100644 index a395f454e..000000000 --- a/packages/render/src/next/validators.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SupportedParsingSpecification } from "frames.js"; - -export function isSpecificationValid( - specification: unknown -): specification is SupportedParsingSpecification { - return ( - typeof specification === "string" && - ["farcaster", "openframes"].includes(specification) - ); -} diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts index 7ad0310b6..cfa6a3d6d 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -17,7 +17,6 @@ import type { ComposerActionFormResponse, ComposerActionState, } from "frames.js/types"; -import type { FarcasterFrameContext } from "./farcaster/frames"; import type { FrameStackAPI } from "./use-frame-stack"; export type OnTransactionArgs = { @@ -78,7 +77,7 @@ export type OnConnectWalletFunc = () => void; export type SignFrameActionFunc< TSignerStorageType = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = ( actionContext: SignerStateActionContext ) => Promise>; @@ -88,7 +87,7 @@ export type UseFetchFrameSignFrameActionFunction< unknown, Record >, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload + TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, > = (arg: { actionContext: TSignerStateActionContext; /** @@ -100,7 +99,7 @@ export type UseFetchFrameSignFrameActionFunction< export type UseFetchFrameOptions< TSignerStorageType = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { stackAPI: FrameStackAPI; stackDispatch: React.Dispatch; @@ -216,7 +215,7 @@ export type UseFetchFrameOptions< export type UseFrameOptions< TSignerStorageType = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { /** skip frame signing, for frames that don't verify signatures */ dangerousSkipSigning?: boolean; @@ -290,7 +289,7 @@ export type UseFrameOptions< type SignerStateActionSharedContext< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { target?: string; frameButton: FrameButton; @@ -307,14 +306,14 @@ type SignerStateActionSharedContext< export type SignerStateDefaultActionContext< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { type?: "default"; } & SignerStateActionSharedContext; export type SignerStateTransactionDataActionContext< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { type: "tx-data"; /** Wallet address used to create the transaction, available only for "tx" button actions */ @@ -323,7 +322,7 @@ export type SignerStateTransactionDataActionContext< export type SignerStateTransactionPostActionContext< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { type: "tx-post"; /** Wallet address used to create the transaction, available only for "tx" button actions */ @@ -333,7 +332,7 @@ export type SignerStateTransactionPostActionContext< export type SignerStateActionContext< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = | SignerStateDefaultActionContext | SignerStateTransactionDataActionContext< @@ -346,7 +345,7 @@ export type SignerStateActionContext< >; export type SignedFrameAction< - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload + TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, > = { body: TFrameActionBodyType; searchParams: URLSearchParams; @@ -357,7 +356,7 @@ export type SignFrameActionFunction< unknown, Record > = SignerStateActionContext, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload + TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, > = ( actionContext: TSignerStateActionContext ) => Promise>; @@ -365,7 +364,7 @@ export type SignFrameActionFunction< export interface SignerStateInstance< TSignerStorageType = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > { signer: TSignerStorageType | null; /** @@ -392,7 +391,7 @@ export type FramePOSTRequest< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > = SignerStateActionContext + > = SignerStateActionContext, > = | { method: "POST"; @@ -418,7 +417,7 @@ export type FrameRequest< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > = SignerStateActionContext + > = SignerStateActionContext, > = FrameGETRequest | FramePOSTRequest; export type FrameStackBase = { @@ -525,13 +524,14 @@ export type FrameReducerActions = action: "RESET_INITIAL_FRAME"; resultOrFrame: ParseResult | Frame; homeframeUrl: string | null | undefined; + specification: SupportedParsingSpecification; }; -type ButtonPressFunction< +export type ButtonPressFunction< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > + >, > = ( frame: Frame, frameButton: FrameButton, @@ -574,7 +574,7 @@ export type CastActionRequest< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > = SignerStateActionContext + > = SignerStateActionContext, > = Omit< FramePOSTRequest, "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" @@ -593,7 +593,7 @@ export type ComposerActionRequest< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > = SignerStateActionContext + > = SignerStateActionContext, > = Omit< FramePOSTRequest, "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" @@ -613,7 +613,7 @@ export type FetchFrameFunction< TSignerStateActionContext extends SignerStateActionContext< unknown, Record - > = SignerStateActionContext + > = SignerStateActionContext, > = ( request: | FrameRequest @@ -629,7 +629,7 @@ export type FetchFrameFunction< export type FrameState< TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext + TFrameContextType extends FrameContext = FrameContext, > = { fetchFrame: FetchFrameFunction< SignerStateActionContext diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx index 609cdc730..26d2309d5 100644 --- a/packages/render/src/ui/frame.base.tsx +++ b/packages/render/src/ui/frame.base.tsx @@ -6,7 +6,8 @@ import { useRef, useState, } from "react"; -import type { FrameStackDone, FrameState } from "../types"; +import type { FrameState } from "../types"; +import type { UseFrameReturnValue } from "../unstable-types"; import type { FrameMessage, FrameUIComponents as BaseFrameUIComponents, @@ -27,7 +28,7 @@ export type FrameUITheme> = Partial>; export type BaseFrameUIProps> = { - frameState: FrameState; + frameState: FrameState | UseFrameReturnValue; /** * Renders also frames that contain only image and at least one button * @@ -123,9 +124,12 @@ export function BaseFrameUI>({ } let frameUiState: FrameUIState; - const previousFrame = ( - frameState.framesStack[frameState.framesStack.length - 1] as FrameStackDone - )?.frameResult?.frame; + const previousFrameStackItem = + frameState.framesStack[frameState.framesStack.length - 1]; + const previousFrame = + previousFrameStackItem?.status === "done" + ? previousFrameStackItem.frameResult.frame + : null; switch (currentFrameStackItem.status) { case "requestError": { diff --git a/packages/render/src/ui/types.ts b/packages/render/src/ui/types.ts index 059149367..b4ed6562b 100644 --- a/packages/render/src/ui/types.ts +++ b/packages/render/src/ui/types.ts @@ -1,12 +1,13 @@ import type { Frame, FrameButton } from "frames.js"; import type { createElement, ReactElement } from "react"; import type { FrameState } from "../types"; +import type { UseFrameReturnValue } from "../unstable-types"; /** * Allows to override styling props on all component of the Frame UI */ export type FrameUIComponentStylingProps< - TStylingProps extends Record + TStylingProps extends Record, > = { Button: TStylingProps; ButtonsContainer: TStylingProps; @@ -30,12 +31,16 @@ export type PartialFrame = Omit, RequiredFrameProperties> & Required>; export type FrameUIState = - | { status: "loading"; id: number; frameState: FrameState } + | { + status: "loading"; + id: number; + frameState: FrameState | UseFrameReturnValue; + } | { id: number; status: "partial"; frame: PartialFrame; - frameState: FrameState; + frameState: FrameState | UseFrameReturnValue; debugImage?: string; isImageLoading: boolean; } @@ -43,7 +48,7 @@ export type FrameUIState = id: number; status: "complete"; frame: Frame; - frameState: FrameState; + frameState: FrameState | UseFrameReturnValue; debugImage?: string; isImageLoading: boolean; }; diff --git a/packages/render/src/use-fetch-frame.ts b/packages/render/src/use-fetch-frame.ts index 32a76465f..e0c0a1d8b 100644 --- a/packages/render/src/use-fetch-frame.ts +++ b/packages/render/src/use-fetch-frame.ts @@ -23,14 +23,12 @@ import type { FramePOSTRequest, FrameStackPending, FrameStackPostPending, - GetFrameResult, SignedFrameAction, SignerStateActionContext, SignerStateDefaultActionContext, UseFetchFrameOptions, UseFetchFrameSignFrameActionFunction, } from "./types"; -import { isParseResult } from "./use-frame-stack"; import { SignatureHandlerDidNotReturnTransactionIdError, TransactionDataErrorResponseError, @@ -39,7 +37,11 @@ import { CastActionUnexpectedResponseError, ComposerActionUnexpectedResponseError, } from "./errors"; -import { tryCall, tryCallAsync } from "./helpers"; +import { + isParseFramesWithReportsResult, + tryCall, + tryCallAsync, +} from "./helpers"; function isErrorMessageResponse( response: unknown @@ -218,12 +220,50 @@ export function useFetchFrame< return; } - const frameResult = (await response.clone().json()) as GetFrameResult; + const parseResult = await tryCallAsync( + () => response.clone().json() as Promise + ); + + if (parseResult instanceof Error) { + stackAPI.markAsFailed({ + endTime, + pendingItem: frameStackPendingItem, + requestError: parseResult, + response, + responseBody: "none", + responseStatus: 500, + }); + + tryCall(() => { + onError(parseResult); + }); + + return; + } + + if (!isParseFramesWithReportsResult(parseResult)) { + const error = new Error("The server returned an unexpected response."); + + stackAPI.markAsFailed({ + endTime, + pendingItem: frameStackPendingItem, + requestError: error, + response, + responseBody: parseResult, + responseStatus: 500, + }); + + tryCall(() => { + onError(error); + }); + + return; + } stackAPI.markAsDone({ pendingItem: frameStackPendingItem, endTime, - frameResult, + frameResult: parseResult[specification], response, }); @@ -440,7 +480,7 @@ export function useFetchFrame< return; } - if (!isParseResult(responseData)) { + if (!isParseFramesWithReportsResult(responseData)) { const error = new Error("The server returned an unexpected response."); stackAPI.markAsFailed({ @@ -461,7 +501,7 @@ export function useFetchFrame< stackAPI.markAsDone({ endTime, - frameResult: responseData, + frameResult: responseData[specification], pendingItem, response, }); diff --git a/packages/render/src/use-frame-stack.ts b/packages/render/src/use-frame-stack.ts index 5c5ac0fc0..b4d51251e 100644 --- a/packages/render/src/use-frame-stack.ts +++ b/packages/render/src/use-frame-stack.ts @@ -1,5 +1,5 @@ import { useMemo, useReducer } from "react"; -import type { Frame } from "frames.js"; +import type { Frame, SupportedParsingSpecification } from "frames.js"; import type { ParseResult } from "frames.js/frame-parsers"; import type { CastActionMessageResponse, @@ -81,6 +81,7 @@ function framesStackReducer( status: "success" as const, reports: {}, frame: action.resultOrFrame, + specification: action.specification, }; return [ @@ -118,6 +119,7 @@ function framesStackReducer( type UseFrameStackOptions = { initialFrame?: Frame | ParseResult; initialFrameUrl?: string | null; + initialSpecification: SupportedParsingSpecification; }; export type FrameStackAPI = { @@ -199,6 +201,7 @@ export type FrameStackAPI = { export function useFrameStack({ initialFrame, initialFrameUrl, + initialSpecification, }: UseFrameStackOptions): [ FramesStack, React.Dispatch, @@ -206,8 +209,8 @@ export function useFrameStack({ ] { const [stack, dispatch] = useReducer( framesStackReducer, - [initialFrame, initialFrameUrl] as const, - ([frame, frameUrl]): FramesStack => { + [initialFrame, initialFrameUrl, initialSpecification] as const, + ([frame, frameUrl, specification]): FramesStack => { if (frame) { const frameResult = isParseResult(frame) ? frame @@ -215,6 +218,7 @@ export function useFrameStack({ reports: {}, frame, status: "success" as const, + specification, }; return [ { diff --git a/packages/render/src/use-frame.tsx b/packages/render/src/use-frame.tsx index 02ae58151..29e97268a 100644 --- a/packages/render/src/use-frame.tsx +++ b/packages/render/src/use-frame.tsx @@ -24,6 +24,7 @@ import type { import { unsignedFrameAction, type FarcasterFrameContext } from "./farcaster"; import { useFrameStack } from "./use-frame-stack"; import { useFetchFrame } from "./use-fetch-frame"; +import { useFreshRef } from "./hooks/use-fresh-ref"; function onMintFallback({ target }: OnMintArgs): void { console.log("Please provide your own onMint function to useFrame() hook."); @@ -189,6 +190,7 @@ export function useFrame< const [framesStack, dispatch, stackAPI] = useFrameStack({ initialFrame: frame, initialFrameUrl: homeframeUrl, + initialSpecification: specification, }); const fetchFrame = useFetchFrame< @@ -229,10 +231,9 @@ export function useFrame< onTransactionSuccess, }); - const fetchFrameRef = useRef(fetchFrame); - fetchFrameRef.current = fetchFrame; - const onErrorRef = useRef(onError); - onErrorRef.current = onError; + const fetchFrameRef = useFreshRef(fetchFrame); + const onErrorRef = useFreshRef(onError); + const specificationRef = useFreshRef(specification); useEffect(() => { if (!frame && homeframeUrl) { @@ -254,9 +255,10 @@ export function useFrame< action: "RESET_INITIAL_FRAME", resultOrFrame: frame, homeframeUrl, + specification: specificationRef.current, }); } - }, [frame, homeframeUrl, dispatch]); + }, [frame, homeframeUrl, dispatch, fetchFrameRef, specificationRef]); const onPostButton = useCallback( async function onPostButton({ From 740ca39faf2b9b4e655027e470fc09fb8386f6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Fri, 25 Oct 2024 09:01:32 +0200 Subject: [PATCH 03/27] chore: make use frame backward compatible --- packages/render/src/helpers.ts | 15 ++++- packages/render/src/next/GET.tsx | 36 ++++++++++-- packages/render/src/next/POST.tsx | 41 +++++++++++-- packages/render/src/next/validators.ts | 10 ++++ packages/render/src/use-fetch-frame.ts | 79 +++++++++++++++++--------- packages/render/src/use-frame-stack.ts | 5 +- 6 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 packages/render/src/next/validators.ts diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index 092a92a08..c15af1749 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -1,4 +1,7 @@ -import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; +import type { + ParseFramesWithReportsResult, + ParseResult, +} from "frames.js/frame-parsers"; export async function tryCallAsync( promiseFn: () => Promise @@ -38,3 +41,13 @@ export function isParseFramesWithReportsResult( "farcaster" in value ); } + +export function isParseResult(value: unknown): value is ParseResult { + return ( + typeof value === "object" && + value !== null && + "status" in value && + !("openframes" in value) && + !("farcaster" in value) + ); +} diff --git a/packages/render/src/next/GET.tsx b/packages/render/src/next/GET.tsx index e92761826..12c685251 100644 --- a/packages/render/src/next/GET.tsx +++ b/packages/render/src/next/GET.tsx @@ -1,8 +1,13 @@ import type { NextRequest } from "next/server"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; +import { getFrame, type GetFrameResult } from "frames.js"; +import { isSpecificationValid } from "./validators"; -export type GETResponse = ParseFramesWithReportsResult | { message: string }; +export type GETResponse = + | ParseFramesWithReportsResult + | GetFrameResult + | { message: string }; /** Proxies fetching a frame through a backend to avoid CORS issues and preserve user IP privacy */ export async function GET(request: Request | NextRequest): Promise { @@ -12,6 +17,9 @@ export async function GET(request: Request | NextRequest): Promise { ? request.nextUrl.searchParams : new URL(request.url).searchParams; const url = searchParams.get("url"); + const specification = searchParams.get("specification") ?? "farcaster"; + const multiSpecificationEnabled = + searchParams.get("multispecification") === "true"; if (!url) { return Response.json({ message: "Invalid URL" } satisfies GETResponse, { @@ -21,9 +29,29 @@ export async function GET(request: Request | NextRequest): Promise { const urlRes = await fetch(url); const html = await urlRes.text(); - const result: ParseFramesWithReportsResult = parseFramesWithReports({ - html, - fallbackPostUrl: url, + + if (multiSpecificationEnabled) { + const result: ParseFramesWithReportsResult = parseFramesWithReports({ + html, + fallbackPostUrl: url, + }); + + return Response.json(result satisfies GETResponse); + } + + if (!isSpecificationValid(specification)) { + return Response.json( + { message: "Invalid specification" } satisfies GETResponse, + { + status: 400, + } + ); + } + + const result = getFrame({ + htmlString: html, + url, + specification, }); return Response.json(result satisfies GETResponse); diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index b02167d36..c0d61e9f7 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -1,9 +1,14 @@ -import type { FrameActionPayload } from "frames.js"; -import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; +import { + getFrame, + type FrameActionPayload, + type GetFrameResult, +} from "frames.js"; +import { type ParseFramesWithReportsResult } from "frames.js/frame-parsers"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import type { JsonObject, JsonValue } from "frames.js/types"; import type { NextRequest } from "next/server"; import { tryCallAsync } from "../helpers"; +import { isSpecificationValid } from "./validators"; export type POSTResponseError = { message: string }; @@ -12,6 +17,7 @@ export type POSTResponseRedirect = { location: string }; export type POSTTransactionResponse = JsonObject; export type POSTResponse = + | GetFrameResult | ParseFramesWithReportsResult | POSTResponseError | POSTResponseRedirect @@ -37,6 +43,9 @@ export async function POST(req: Request | NextRequest): Promise { const isPostRedirect = searchParams.get("postType") === "post_redirect"; const isTransactionRequest = searchParams.get("postType") === "tx"; const postUrl = searchParams.get("postUrl"); + const multiSpecificationEnabled = + searchParams.get("multispecification") === "true"; + const specification = searchParams.get("specification") ?? "farcaster"; if (!postUrl) { return Response.json( @@ -170,9 +179,31 @@ export async function POST(req: Request | NextRequest): Promise { } const html = await response.text(); - const result: ParseFramesWithReportsResult = parseFramesWithReports({ - html, - fallbackPostUrl: body.untrustedData.url, + + if (multiSpecificationEnabled) { + const result = parseFramesWithReports({ + html, + fallbackPostUrl: body.untrustedData.url, + fromRequestMethod: "POST", + }); + + return Response.json(result satisfies ParseFramesWithReportsResult); + } + + if (!isSpecificationValid(specification)) { + return Response.json( + { + message: "Invalid specification", + } satisfies POSTResponseError, + { status: 400 } + ); + } + + const result = getFrame({ + htmlString: html, + url: body.untrustedData.url, + fromRequestMethod: "POST", + specification, }); return Response.json(result satisfies POSTResponse); diff --git a/packages/render/src/next/validators.ts b/packages/render/src/next/validators.ts new file mode 100644 index 000000000..a395f454e --- /dev/null +++ b/packages/render/src/next/validators.ts @@ -0,0 +1,10 @@ +import type { SupportedParsingSpecification } from "frames.js"; + +export function isSpecificationValid( + specification: unknown +): specification is SupportedParsingSpecification { + return ( + typeof specification === "string" && + ["farcaster", "openframes"].includes(specification) + ); +} diff --git a/packages/render/src/use-fetch-frame.ts b/packages/render/src/use-fetch-frame.ts index e0c0a1d8b..8c85f7cb3 100644 --- a/packages/render/src/use-fetch-frame.ts +++ b/packages/render/src/use-fetch-frame.ts @@ -39,6 +39,7 @@ import { } from "./errors"; import { isParseFramesWithReportsResult, + isParseResult, tryCall, tryCallAsync, } from "./helpers"; @@ -241,30 +242,41 @@ export function useFetchFrame< return; } - if (!isParseFramesWithReportsResult(parseResult)) { - const error = new Error("The server returned an unexpected response."); - - stackAPI.markAsFailed({ - endTime, + if (isParseFramesWithReportsResult(parseResult)) { + stackAPI.markAsDone({ pendingItem: frameStackPendingItem, - requestError: error, + endTime, + frameResult: parseResult[specification], response, - responseBody: parseResult, - responseStatus: 500, }); - tryCall(() => { - onError(error); + return; + } + + if (isParseResult(parseResult)) { + stackAPI.markAsDone({ + pendingItem: frameStackPendingItem, + endTime, + frameResult: parseResult, + response, }); return; } - stackAPI.markAsDone({ - pendingItem: frameStackPendingItem, + const error = new Error("The server returned an unexpected response."); + + stackAPI.markAsFailed({ endTime, - frameResult: parseResult[specification], + pendingItem: frameStackPendingItem, + requestError: error, response, + responseBody: parseResult, + responseStatus: 500, + }); + + tryCall(() => { + onError(error); }); return; @@ -480,33 +492,46 @@ export function useFetchFrame< return; } - if (!isParseFramesWithReportsResult(responseData)) { - const error = new Error("The server returned an unexpected response."); - - stackAPI.markAsFailed({ + if (isParseFramesWithReportsResult(responseData)) { + stackAPI.markAsDone({ endTime, + frameResult: responseData[specification], pendingItem, - requestError: error, response, - responseBody: responseData, - responseStatus: 500, }); - tryCall(() => { - onError(error); + + tryCall(() => options?.onSuccess?.()); + + return; + } + + if (isParseResult(responseData)) { + stackAPI.markAsDone({ + endTime, + frameResult: responseData, + pendingItem, + response, }); - tryCall(() => options?.onError?.(error)); + + tryCall(() => options?.onSuccess?.()); return; } - stackAPI.markAsDone({ + const error = new Error("The server returned an unexpected response."); + + stackAPI.markAsFailed({ endTime, - frameResult: responseData[specification], pendingItem, + requestError: error, response, + responseBody: responseData, + responseStatus: 500, }); - - tryCall(() => options?.onSuccess?.()); + tryCall(() => { + onError(error); + }); + tryCall(() => options?.onError?.(error)); return; } diff --git a/packages/render/src/use-frame-stack.ts b/packages/render/src/use-frame-stack.ts index b4d51251e..2c49ae757 100644 --- a/packages/render/src/use-frame-stack.ts +++ b/packages/render/src/use-frame-stack.ts @@ -16,15 +16,12 @@ import type { SignedFrameAction, SignerStateActionContext, } from "./types"; +import { isParseResult } from "./helpers"; function computeDurationInSeconds(start: Date, end: Date): number { return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); } -export function isParseResult(result: unknown): result is ParseResult { - return typeof result === "object" && result !== null && "status" in result; -} - function framesStackReducer( state: FramesStack, action: FrameReducerActions From a6014cfe6ad92a96696c9db8db5bfc7e9ae2d016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Fri, 25 Oct 2024 09:07:52 +0200 Subject: [PATCH 04/27] feat: unstable multi specification api --- packages/render/package.json | 40 + packages/render/src/collapsed-frame-ui.tsx | 3 +- packages/render/src/unstable-types.ts | 375 ++++++ .../render/src/unstable-use-fetch-frame.ts | 1159 +++++++++++++++++ .../render/src/unstable-use-frame-state.ts | 553 ++++++++ packages/render/src/unstable-use-frame.tsx | 600 +++++++++ 6 files changed, 2729 insertions(+), 1 deletion(-) create mode 100644 packages/render/src/unstable-types.ts create mode 100644 packages/render/src/unstable-use-fetch-frame.ts create mode 100644 packages/render/src/unstable-use-frame-state.ts create mode 100644 packages/render/src/unstable-use-frame.tsx diff --git a/packages/render/package.json b/packages/render/package.json index 8b351b861..58ed8d34d 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -120,6 +120,46 @@ "default": "./dist/use-frame.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-fetch-frame": { + "import": { + "types": "./dist/unstable-use-fetch-frame.d.ts", + "default": "./dist/unstable-use-fetch-frame.js" + }, + "require": { + "types": "./dist/unstable-use-fetch-frame.d.cts", + "default": "./dist/unstable-use-fetch-frame.cjs" + } + }, + "./unstable-use-frame": { + "import": { + "types": "./dist/unstable-use-frame.d.ts", + "default": "./dist/unstable-use-frame.js" + }, + "require": { + "types": "./dist/unstable-use-frame.d.cts", + "default": "./dist/unstable-use-frame.cjs" + } + }, + "./unstable-types": { + "import": { + "types": "./dist/unstable-types.d.ts", + "default": "./dist/unstable-types.js" + }, + "require": { + "types": "./dist/unstable-types.d.cts", + "default": "./dist/unstable-types.cjs" + } + }, "./identity/anonymous": { "import": { "types": "./dist/identity/anonymous/index.d.ts", diff --git a/packages/render/src/collapsed-frame-ui.tsx b/packages/render/src/collapsed-frame-ui.tsx index bdd358ad8..1360497a0 100644 --- a/packages/render/src/collapsed-frame-ui.tsx +++ b/packages/render/src/collapsed-frame-ui.tsx @@ -2,6 +2,7 @@ import type { ImgHTMLAttributes } from "react"; import React, { useState } from "react"; import type { Frame } from "frames.js"; import type { FrameTheme, FrameState } from "./types"; +import type { UseFrameReturnValue } from "./unstable-types"; const defaultTheme: Required = { buttonBg: "#fff", @@ -20,7 +21,7 @@ const getThemeWithDefaults = (theme: FrameTheme): FrameTheme => { }; export type CollapsedFrameUIProps = { - frameState: FrameState; + frameState: FrameState | UseFrameReturnValue; theme?: FrameTheme; FrameImage?: React.FC & { src: string }>; allowPartialFrame?: boolean; diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts new file mode 100644 index 000000000..ec9b722ac --- /dev/null +++ b/packages/render/src/unstable-types.ts @@ -0,0 +1,375 @@ +import type { + FrameButtonLink, + FrameButtonTx, + SupportedParsingSpecification, + TransactionTargetResponse, + TransactionTargetResponseSendTransaction, + TransactionTargetResponseSignTypedDataV4, +} from "frames.js"; +import type { + ParseFramesWithReportsResult, + ParseResultWithFrameworkDetails, +} from "frames.js/frame-parsers"; +import type { Dispatch } from "react"; +import type { CastActionResponse, ComposerActionState } from "frames.js/types"; +import type { + ButtonPressFunction, + CastActionButtonPressFunction, + ComposerActionButtonPressFunction, + FrameContext, + FramePOSTRequest, + FrameRequest, + FrameStackBase, + FrameStackDoneRedirect, + FrameStackMessage, + FrameStackPending, + FrameStackRequestError, + OnComposerFormActionFunc, + OnConnectWalletFunc, + OnMintArgs, + OnSignatureFunc, + OnTransactionFunc, + SignerStateActionContext, + SignerStateInstance, +} from "./types"; +import type { FrameState, FrameStateAPI } from "./unstable-use-frame-state"; + +type ResolvedSpecification = { + /** + * Specification that should be used to render the frame. + */ + specification: SupportedParsingSpecification; + /** + * Signer that will be used to sign all actions that require signers. + */ + signerState: SignerStateInstance; + /** + * The context of this frame, used for generating Frame Action payloads + */ + frameContext: FrameContext; +}; + +export type ResolveSpecificationFunctionArg = { + parseResult: ParseFramesWithReportsResult; +}; + +export type ResolveSpecificationFunction = ( + arg: ResolveSpecificationFunctionArg +) => ResolvedSpecification; + +export type ResolvedCastOrComposerActionContext = { + signerState: SignerStateInstance; + frameContext: FrameContext; +}; + +export type ResolveCastOrComposerActionContextFunctionArg = { + action: + | { type: "cast"; action: CastActionResponse } + | { + type: "compose"; + action: CastActionResponse; + composerActionState: ComposerActionState; + }; +}; + +export type ResolveCastOrComposerActionContextFunction = ( + arg: ResolveCastOrComposerActionContextFunctionArg +) => ResolvedCastOrComposerActionContext; + +export type UseFrameOptions = { + /** the route used to POST frame actions. The post_url will be added as a the `url` query parameter */ + frameActionProxy: string; + /** the route used to GET the initial frame via proxy */ + frameGetProxy: string; + /** + * Called on initial frame load. + * + * The function is called again if: + * 1. initial frame changes + * 2. homeframeUrl changes + * 3. reset() method on FrameState is called + */ + resolveSpecification: ResolveSpecificationFunction; + /** + * Called when cast or composer action is required. + */ + resolveCastOrComposerActionSigner: ResolveCastOrComposerActionContextFunction; + /** + * The url of the homeframe, if null / undefined won't load a frame nor render it. + * + * If the value changes the frame state is reset. + */ + homeframeUrl: string | null | undefined; + /** + * The initial frame. if not specified will fetch it from the homeframeUrl prop. + * + * Value should be memoized otherwise it will reset the frame state. + */ + frame?: ParseFramesWithReportsResult | null; + /** + * connected wallet address of the user, send to the frame for transaction requests + */ + connectedAddress: `0x${string}` | undefined; + /** a function to handle mint buttons */ + onMint?: (t: OnMintArgs) => void; + /** a function to handle transaction buttons that returned transaction data from the target, returns the transaction hash or null */ + onTransaction?: OnTransactionFunc; + /** Transaction data suffix */ + transactionDataSuffix?: `0x${string}`; + /** A function to handle transaction buttons that returned signature data from the target, returns signature hash or null */ + onSignature?: OnSignatureFunc; + /** + * Called when user presses transaction button but there is no wallet connected. + */ + onConnectWallet?: OnConnectWalletFunc; + /** + * Extra data appended to the frame action payload + */ + extraButtonRequestPayload?: Record; + /** + * This function can be used to customize how error is reported to the user. + */ + onError?: (error: Error) => void; + /** + * This function can be used to customize how the link button click is handled. + */ + onLinkButtonClick?: (button: FrameButtonLink) => void; +} & Partial< + Pick< + UseFetchFrameOptions, + | "fetchFn" + | "onRedirect" + | "onComposerFormAction" + | "onTransactionDataError" + | "onTransactionDataStart" + | "onTransactionDataSuccess" + | "onTransactionError" + | "onTransactionStart" + | "onTransactionSuccess" + | "onTransactionProcessingError" + | "onTransactionProcessingStart" + | "onTransactionProcessingSuccess" + > +>; + +export type FrameStackDone = FrameStackBase & { + request: FrameRequest; + response: Response; + frameResult: ParseResultWithFrameworkDetails; + status: "done"; +}; + +export type FramesStackItem = + | FrameStackPending + | FrameStackDone + | FrameStackDoneRedirect + | FrameStackRequestError + | FrameStackMessage; + +export type UseFrameReturnValue = { + fetchFrame: FetchFrameFunction; + clearFrameStack: () => void; + dispatchFrameStack: Dispatch; + /** The frame at the top of the stack (at index 0) */ + currentFrameStackItem: FramesStackItem | undefined; + /** A stack of frames with additional context, with the most recent frame at index 0 */ + framesStack: FramesStack; + inputText: string; + setInputText: (s: string) => void; + onButtonPress: ButtonPressFunction>; + homeframeUrl: string | null | undefined; + onCastActionButtonPress: CastActionButtonPressFunction; + onComposerActionButtonPress: ComposerActionButtonPressFunction; +}; + +export type FramesStack = FramesStackItem[]; + +export type FrameReducerActions = + | { + action: "LOAD"; + item: FrameStackPending; + } + | { + action: "REQUEST_ERROR"; + pendingItem: FrameStackPending; + item: FrameStackRequestError; + } + | { + action: "DONE_REDIRECT"; + pendingItem: FrameStackPending; + item: FrameStackDoneRedirect; + } + | { + action: "DONE_WITH_ERROR_MESSAGE"; + pendingItem: FrameStackPending; + item: Exclude; + } + | { + action: "DONE_WITH_MESSAGE"; + pendingItem: FrameStackPending; + item: Exclude; + } + | { + action: "DONE"; + pendingItem: FrameStackPending; + parseResult: ParseFramesWithReportsResult; + response: Response; + endTime: Date; + } + | { action: "CLEAR" } + | { + action: "RESET_INITIAL_FRAME"; + parseResult: ParseFramesWithReportsResult; + homeframeUrl: string; + }; + +export type UseFetchFrameOptions = { + frameState: FrameState; + frameStateAPI: FrameStateAPI; + /** + * URL or path to the frame proxy handling GET requests. + */ + frameGetProxy: string; + /** + * URL or path to the frame proxy handling POST requests. + */ + frameActionProxy: string; + /** + * Extra payload to be sent with the POST request. + */ + extraButtonRequestPayload?: Record; + /** + * Called after transaction data has been returned from the server and user needs to approve the transaction. + */ + onTransaction: OnTransactionFunc; + /** Transaction data suffix */ + transactionDataSuffix?: `0x${string}`; + onSignature: OnSignatureFunc; + onComposerFormAction: OnComposerFormActionFunc; + /** + * This function can be used to customize how error is reported to the user. + * + * Should be memoized + */ + onError?: (error: Error) => void; + /** + * Custom fetch compatible function used to make requests. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API + */ + fetchFn: typeof fetch; + /** + * This function is called when the frame returns a redirect in response to post_redirect button click. + */ + onRedirect: (location: URL) => void; + /** + * Called when user presses the tx button just before the action is signed and sent to the server + * to obtain the transaction data. + */ + onTransactionDataStart?: (event: { button: FrameButtonTx }) => void; + /** + * Called when transaction data has been successfully returned from the server. + */ + onTransactionDataSuccess?: (event: { + button: FrameButtonTx; + data: TransactionTargetResponse; + }) => void; + /** + * Called when anything failed between onTransactionDataStart and obtaining the transaction data. + */ + onTransactionDataError?: (error: Error) => void; + /** + * Called before onTransaction() is called + * Called after onTransactionDataSuccess() is called + */ + onTransactionStart?: (event: { + button: FrameButtonTx; + data: TransactionTargetResponseSendTransaction; + }) => void; + /** + * Called when onTransaction() returns a transaction id + */ + onTransactionSuccess?: (event: { button: FrameButtonTx }) => void; + /** + * Called when onTransaction() fails to return a transaction id + */ + onTransactionError?: (error: Error) => void; + /** + * Called before onSignature() is called + * Called after onTransactionDataSuccess() is called + */ + onSignatureStart?: (event: { + button: FrameButtonTx; + data: TransactionTargetResponseSignTypedDataV4; + }) => void; + /** + * Called when onSignature() returns a transaction id + */ + onSignatureSuccess?: (event: { button: FrameButtonTx }) => void; + /** + * Called when onSignature() fails to return a transaction id + */ + onSignatureError?: (error: Error) => void; + /** + * Called after either onSignatureSuccess() or onTransactionSuccess() is called just before the transaction is sent to the server. + */ + onTransactionProcessingStart?: (event: { + button: FrameButtonTx; + transactionId: `0x${string}`; + }) => void; + /** + * Called after the transaction has been successfully sent to the server and returned a success response. + */ + onTransactionProcessingSuccess?: (event: { + button: FrameButtonTx; + transactionId: `0x${string}`; + }) => void; + /** + * Called when the transaction has been sent to the server but the server returned an error. + */ + onTransactionProcessingError?: (error: Error) => void; +}; + +export type FetchFrameFunction = ( + request: + | FrameRequest + | CastActionRequest + | ComposerActionRequest, + /** + * If true, the frame stack will be cleared before the new frame is loaded + * + * @defaultValue false + */ + shouldClear?: boolean +) => Promise; + +export type CastActionRequest = Omit< + FramePOSTRequest, + "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" +> & { + method: "CAST_ACTION"; + signerState: SignerStateInstance; + action: CastActionResponse & { + url: string; + }; + signerStateActionContext: Omit< + FramePOSTRequest["signerStateActionContext"], + "frameButton" | "inputText" | "state" + >; +}; + +export type ComposerActionRequest = Omit< + FramePOSTRequest, + "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" +> & { + method: "COMPOSER_ACTION"; + signerState: SignerStateInstance; + action: CastActionResponse & { + url: string; + }; + composerActionState: ComposerActionState; + signerStateActionContext: Omit< + FramePOSTRequest["signerStateActionContext"], + "frameButton" | "inputText" | "state" + >; +}; diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts new file mode 100644 index 000000000..1ad995565 --- /dev/null +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -0,0 +1,1159 @@ +/* eslint-disable no-console -- provide feedback to console */ +import type { FrameButtonPost, TransactionTargetResponse } from "frames.js"; +import type { types } from "frames.js/core"; +import type { + CastActionFrameResponse, + ComposerActionFormResponse, + ComposerActionStateFromMessage, + ErrorMessageResponse, +} from "frames.js/types"; +import { hexToBytes } from "viem"; +import type { FarcasterFrameContext } from "./farcaster"; +import { + SignatureHandlerDidNotReturnTransactionIdError, + TransactionDataErrorResponseError, + TransactionDataTargetMalformedError, + TransactionHandlerDidNotReturnTransactionIdError, + CastActionUnexpectedResponseError, + ComposerActionUnexpectedResponseError, +} from "./errors"; +import { + isParseFramesWithReportsResult, + tryCall, + tryCallAsync, +} from "./helpers"; +import type { + CastActionRequest, + ComposerActionRequest, + FetchFrameFunction, + UseFetchFrameOptions, +} from "./unstable-types"; +import type { + FrameGETRequest, + FramePOSTRequest, + FrameStackPending, + FrameStackPostPending, + SignedFrameAction, + SignerStateActionContext, + SignerStateDefaultActionContext, + SignerStateInstance, +} from "./types"; + +function isErrorMessageResponse( + response: unknown +): response is ErrorMessageResponse { + return ( + typeof response === "object" && + response !== null && + "message" in response && + typeof response.message === "string" + ); +} + +function isComposerFormActionResponse( + response: unknown +): response is ComposerActionFormResponse { + return ( + typeof response === "object" && + response !== null && + "type" in response && + response.type === "form" + ); +} + +function isCastActionFrameResponse( + response: unknown +): response is CastActionFrameResponse { + return ( + typeof response === "object" && + response !== null && + "type" in response && + response.type === "frame" + ); +} + +function isCastMessageResponse( + response: unknown +): response is types.CastActionMessageResponse { + return ( + typeof response === "object" && + response !== null && + "message" in response && + typeof response.message === "string" + ); +} + +function defaultErrorHandler(error: Error): void { + console.error(error); +} + +export function useFetchFrame({ + frameStateAPI, + frameState, + frameActionProxy, + frameGetProxy, + extraButtonRequestPayload, + onTransaction, + transactionDataSuffix, + onSignature, + onError = defaultErrorHandler, + fetchFn, + onRedirect, + onComposerFormAction, + onTransactionDataError, + onTransactionDataStart, + onTransactionDataSuccess, + onTransactionError, + onTransactionStart, + onTransactionSuccess, + onSignatureError, + onSignatureStart, + onSignatureSuccess, + onTransactionProcessingError, + onTransactionProcessingStart, + onTransactionProcessingSuccess, +}: UseFetchFrameOptions): FetchFrameFunction { + async function handleFailedResponse({ + response, + endTime, + frameStackPendingItem, + onError: onErrorInternal, + }: { + endTime: Date; + response: Response; + frameStackPendingItem: FrameStackPending; + onError?: (error: Error) => void; + }): Promise { + if (response.ok) { + throw new TypeError( + "handleFailedResponse called with a successful response" + ); + } + + const responseBody = await getResponseBody(response); + + if (response.status >= 400 && response.status < 500) { + // handle error message only for actions (POST method) + if ( + frameStackPendingItem.method === "POST" && + isErrorMessageResponse(responseBody) + ) { + frameStateAPI.markAsDoneWithErrorMessage({ + pendingItem: frameStackPendingItem, + endTime, + response, + responseData: responseBody, + }); + const error = new Error(responseBody.message); + tryCall(() => { + onError(error); + }); + tryCall(() => onErrorInternal?.(error)); + + return; + } + } + + const requestError = new Error( + `The server returned an error but it does not contain message property. Status code: ${response.status}` + ); + + frameStateAPI.markAsFailed({ + endTime, + pendingItem: frameStackPendingItem, + requestError, + responseStatus: response.status, + response, + responseBody, + }); + tryCall(() => { + onError(requestError); + }); + tryCall(() => onErrorInternal?.(requestError)); + } + + async function fetchGETRequest( + request: FrameGETRequest, + shouldClear?: boolean + ): Promise { + if (shouldClear) { + // this clears initial frame since that is loading from SSR since we aren't able to finish it. + // not an ideal solution + frameStateAPI.clear(); + } + + const frameStackPendingItem = frameStateAPI.createGetPendingItem({ + request, + }); + + const response = await fetchProxied({ + proxyUrl: frameGetProxy, + fetchFn, + url: request.url, + }); + + const endTime = new Date(); + + if (response instanceof Response) { + if (!response.ok) { + await handleFailedResponse({ + response, + endTime, + frameStackPendingItem, + }); + + return; + } + + const parseResult = await tryCallAsync( + () => response.clone().json() as Promise + ); + + if (parseResult instanceof Error) { + frameStateAPI.markAsFailed({ + pendingItem: frameStackPendingItem, + endTime, + requestError: parseResult, + response, + responseBody: "none", + responseStatus: 500, + }); + + tryCall(() => { + onError(parseResult); + }); + + return; + } + + if (!isParseFramesWithReportsResult(parseResult)) { + const error = new Error("The server returned an unexpected response."); + + frameStateAPI.markAsFailed({ + pendingItem: frameStackPendingItem, + endTime, + requestError: error, + response, + responseBody: "none", + responseStatus: 500, + }); + + tryCall(() => { + onError(error); + }); + + return; + } + + frameStateAPI.markAsDone({ + pendingItem: frameStackPendingItem, + endTime, + parseResult, + response, + }); + + return; + } + + frameStateAPI.markAsFailed({ + pendingItem: frameStackPendingItem, + endTime, + requestError: response, + response: null, + responseBody: "none", + responseStatus: 500, + }); + tryCall(() => { + onError(response); + }); + } + + async function fetchPOSTRequest( + request: FramePOSTRequest, + options?: { + preflightRequest?: { + pendingFrameStackItem: FrameStackPostPending; + startTime: Date; + }; + shouldClear?: boolean; + onError?: (error: Error) => void; + onSuccess?: () => void; + } + ): Promise { + let pendingItem: FrameStackPostPending; + + if (frameState.type === "not-initialized") { + throw new Error( + "POST Request cannot be made before the frame is initialized" + ); + } + + if (options?.shouldClear) { + frameStateAPI.clear(); + } + + // get rid of address from request.signerStateActionContext.frameContext and pass that to sign frame action + const signedDataOrError = await signAndGetFrameActionBodyPayload({ + signerStateActionContext: request.signerStateActionContext, + signerState: frameState.signerState, + }); + + if (signedDataOrError instanceof Error) { + if (options?.preflightRequest) { + // mark preflight request as failed + frameStateAPI.markAsFailed({ + pendingItem: options.preflightRequest.pendingFrameStackItem, + endTime: new Date(), + requestError: signedDataOrError, + response: null, // there is no response because didn't even got to request + responseBody: "none", + responseStatus: 500, + }); + } + tryCall(() => { + onError(signedDataOrError); + }); + tryCall(() => options?.onError?.(signedDataOrError)); + + return; + } + + // if there is no preflight happening (for example in case of transactions we first fetch transaction data and then post it to frame) + // in that case options are passed so we can manipulate the pending item + if (!options?.preflightRequest) { + pendingItem = frameStateAPI.createPostPendingItem({ + action: signedDataOrError, + request, + }); + } else { + pendingItem = options.preflightRequest.pendingFrameStackItem; + } + + const response = await fetchProxied({ + proxyUrl: frameActionProxy, + fetchFn, + frameAction: signedDataOrError, + extraRequestPayload: extraButtonRequestPayload, + }); + + const endTime = new Date(); + + async function handleRedirect({ + response: res, + currentPendingItem, + onError: onErrorInternal, + onSuccess: onSuccessInternal, + }: { + response: Response; + currentPendingItem: FrameStackPostPending; + onError?: (error: Error) => void; + onSuccess?: () => void; + }): Promise { + // check that location is proper fully formatted url + try { + let location = res.headers.get("location"); + + if (!location) { + const responseData = (await res.clone().json()) as + | Record + | string + | null + | number; + + if ( + responseData && + typeof responseData === "object" && + "location" in responseData && + typeof responseData.location === "string" + ) { + location = responseData.location; + } + } + + if (!location) { + throw new Error( + `Response data does not contain 'location' key and no 'location' header is found.` + ); + } + + // check the URL is valid + const locationUrl = new URL(location); + + // Reject non-http(s) URLs + if ( + locationUrl.protocol !== "http:" && + locationUrl.protocol !== "https:" + ) { + throw new Error( + `Redirect location ${location} is not a valid HTTP or HTTPS URL.` + ); + } + + tryCall(() => { + onRedirect(locationUrl); + }); + tryCall(() => onSuccessInternal?.()); + + frameStateAPI.markAsDoneWithRedirect({ + pendingItem: currentPendingItem, + endTime, + location, + response: res.clone(), + responseBody: await res.clone().text(), + }); + } catch (e) { + const error = + e instanceof Error + ? e + : new Error( + "Response body must be a json with 'location' property or response 'Location' header must contain fully qualified URL." + ); + + frameStateAPI.markAsFailedWithRequestError({ + pendingItem: currentPendingItem, + error, + response: res, + endTime, + responseBody: await res.clone().text(), + }); + tryCall(() => onErrorInternal?.(error)); + tryCall(() => { + onError(error); + }); + } + } + + if (response instanceof Response) { + // handle valid redirect + if (response.status === 302) { + await handleRedirect({ + response, + currentPendingItem: pendingItem, + onError: options?.onError, + onSuccess: options?.onSuccess, + }); + + return; + } + + if (!response.ok) { + await handleFailedResponse({ + response, + endTime, + frameStackPendingItem: pendingItem, + onError: options?.onError, + }); + + return; + } + + const responseData = await tryCall( + () => response.clone().json() as Promise + ); + + if (responseData instanceof Error) { + frameStateAPI.markAsFailed({ + endTime, + pendingItem, + requestError: responseData, + response, + responseBody: "none", + responseStatus: 500, + }); + tryCall(() => { + onError(responseData); + }); + tryCall(() => options?.onError?.(responseData)); + + return; + } + + if (!isParseFramesWithReportsResult(responseData)) { + const error = new Error("The server returned an unexpected response."); + + frameStateAPI.markAsFailed({ + endTime, + pendingItem, + requestError: error, + response, + responseBody: responseData, + responseStatus: 500, + }); + tryCall(() => { + onError(error); + }); + tryCall(() => options?.onError?.(error)); + + return; + } + + frameStateAPI.markAsDone({ + endTime, + parseResult: responseData, + pendingItem, + response, + }); + + tryCall(() => options?.onSuccess?.()); + + return; + } + + frameStateAPI.markAsFailed({ + endTime, + pendingItem, + requestError: response, + response: new Response(response.message, { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + responseBody: "none", + responseStatus: 500, + }); + tryCall(() => { + onError(response); + }); + tryCall(() => options?.onError?.(response)); + } + + async function fetchTransactionRequest( + request: FramePOSTRequest, + shouldClear?: boolean + ): Promise { + if ("source" in request) { + throw new Error( + "Invalid request, transaction should be invoked only from a Frame. It was probably invoked from cast or composer action." + ); + } + + if (frameState.type === "not-initialized") { + throw new Error( + "Transaction Request cannot be made before the frame is initialized" + ); + } + + const button = request.frameButton; + const sourceFrame = request.sourceFrame; + + if (button.action !== "tx") { + throw new Error("Invalid frame button action, tx expected"); + } + + if (request.signerStateActionContext.type !== "tx-data") { + throw new Error( + "Invalid signer state action context type, tx-data expected" + ); + } + + if (shouldClear) { + frameStateAPI.clear(); + } + + tryCall(() => onTransactionDataStart?.({ button })); + + const signedTransactionDataActionOrError = await tryCallAsync(() => + frameState.signerState.signFrameAction(request.signerStateActionContext) + ); + + if (signedTransactionDataActionOrError instanceof Error) { + tryCall(() => { + onError(signedTransactionDataActionOrError); + }); + tryCall(() => + onTransactionDataError?.(signedTransactionDataActionOrError) + ); + return; + } + + const transactionDataStartTime = new Date(); + const transactionDataResponse = await fetchProxied({ + proxyUrl: frameActionProxy, + frameAction: signedTransactionDataActionOrError, + fetchFn, + extraRequestPayload: extraButtonRequestPayload, + }); + const transactionDataEndTime = new Date(); + + if (transactionDataResponse instanceof Error) { + const pendingItem = frameStateAPI.createPostPendingItem({ + action: signedTransactionDataActionOrError, + request, + startTime: transactionDataStartTime, + }); + + frameStateAPI.markAsFailed({ + endTime: transactionDataEndTime, + pendingItem, + requestError: transactionDataResponse, + response: null, + responseBody: "none", + responseStatus: 500, + }); + tryCall(() => onTransactionDataError?.(transactionDataResponse)); + tryCall(() => { + onError(transactionDataResponse); + }); + + return; + } + + if (!transactionDataResponse.ok) { + // show as error + const pendingItem = frameStateAPI.createPostPendingItem({ + action: signedTransactionDataActionOrError, + request, + startTime: transactionDataStartTime, + }); + + await handleFailedResponse({ + response: transactionDataResponse, + endTime: transactionDataEndTime, + frameStackPendingItem: pendingItem, + }); + + tryCall(() => + onTransactionDataError?.( + new TransactionDataErrorResponseError(transactionDataResponse.clone()) + ) + ); + + return; + } + + function isTransactionTargetResponse( + response: unknown + ): response is TransactionTargetResponse { + return ( + typeof response === "object" && + response !== null && + "method" in response + ); + } + + // try to parse and catch the error, this is too optimistic + + const transactionData = await tryCallAsync(() => + transactionDataResponse.clone().json() + ); + + if (!isTransactionTargetResponse(transactionData)) { + const pendingItem = frameStateAPI.createPostPendingItem({ + action: signedTransactionDataActionOrError, + request, + startTime: transactionDataStartTime, + }); + + const error = new TransactionDataTargetMalformedError( + transactionDataResponse.clone() + ); + + frameStateAPI.markAsFailed({ + endTime: transactionDataEndTime, + pendingItem, + requestError: error, + response: transactionDataResponse, + responseBody: transactionData, + responseStatus: 500, + }); + tryCall(() => onTransactionDataError?.(error)); + tryCall(() => { + onError(error); + }); + + return; + } + + tryCall(() => + onTransactionDataSuccess?.({ button, data: transactionData }) + ); + + let transactionIdOrError: `0x${string}` | Error; + + // get transaction id or signature id from transaction data + if (transactionData.method === "eth_sendTransaction") { + // Add transaction data suffix + if ( + transactionData.params.data && + transactionData.attribution !== false && + transactionDataSuffix && + // Has a function signature + hexToBytes(transactionData.params.data).length > 4 + ) { + transactionData.params.data = (transactionData.params.data + + transactionDataSuffix.slice(2)) as `0x${string}`; + } + + tryCall(() => onTransactionStart?.({ button, data: transactionData })); + + transactionIdOrError = await tryCallAsync(() => + onTransaction({ + frame: sourceFrame, + frameButton: request.frameButton, + transactionData, + }).then((transactionId) => { + if (!transactionId) { + return new TransactionHandlerDidNotReturnTransactionIdError(); + } + + return transactionId; + }) + ); + + if (!(transactionIdOrError instanceof Error)) { + tryCall(() => onTransactionSuccess?.({ button })); + } else { + tryCall(() => onTransactionError?.(transactionIdOrError as Error)); + } + } else { + tryCall(() => onSignatureStart?.({ button, data: transactionData })); + + transactionIdOrError = await tryCallAsync(() => + onSignature({ + frame: sourceFrame, + frameButton: request.frameButton, + signatureData: transactionData, + }).then((signatureHash) => { + if (!signatureHash) { + return new SignatureHandlerDidNotReturnTransactionIdError(); + } + + return signatureHash; + }) + ); + + if (!(transactionIdOrError instanceof Error)) { + tryCall(() => onSignatureSuccess?.({ button })); + } else { + tryCall(() => onSignatureError?.(transactionIdOrError as Error)); + } + } + + if (transactionIdOrError instanceof Error) { + const pendingItem = frameStateAPI.createPostPendingItem({ + action: signedTransactionDataActionOrError, + request, + }); + + frameStateAPI.markAsFailed({ + pendingItem, + endTime: new Date(), + requestError: transactionIdOrError, + response: null, + responseBody: "none", + responseStatus: 500, + }); + tryCall(() => { + onError(transactionIdOrError); + }); + + return; + } + + tryCall(() => + onTransactionProcessingStart?.({ + button, + transactionId: transactionIdOrError, + }) + ); + + const startTime = new Date(); + const pendingItem = frameStateAPI.createPostPendingItem({ + action: signedTransactionDataActionOrError, + request, + }); + + await fetchPOSTRequest( + { + ...request, + signerStateActionContext: { + ...request.signerStateActionContext, + type: "tx-post", + // include transactionId in payload + transactionId: transactionIdOrError, + // override target so the the request is sent to proper endpoint + target: button.post_url || sourceFrame.postUrl || button.target, + }, + }, + { + // we are continuing with the same pending item + preflightRequest: { + pendingFrameStackItem: pendingItem, + startTime, + }, + onError(error) { + tryCall(() => onTransactionProcessingError?.(error)); + }, + onSuccess() { + tryCall(() => + onTransactionProcessingSuccess?.({ + button, + transactionId: transactionIdOrError, + }) + ); + }, + } + ); + } + + async function fetchCastActionRequest( + request: CastActionRequest, + shouldClear = false + ): Promise { + const frameButton: FrameButtonPost = { + action: "post", + label: request.action.name, + target: request.action.action.postUrl || request.action.url, + }; + const signerStateActionContext: SignerStateDefaultActionContext = { + ...request.signerStateActionContext, + type: "default", + frameButton, + }; + + const signedDataOrError = await signAndGetFrameActionBodyPayload({ + signerStateActionContext, + signerState: request.signerState, + }); + + if (shouldClear) { + frameStateAPI.clear(); + } + + if (signedDataOrError instanceof Error) { + tryCall(() => { + onError(signedDataOrError); + }); + throw signedDataOrError; + } + + // create pending item but do not dispatch it + const pendingItem = frameStateAPI.createCastOrComposerActionPendingItem({ + action: signedDataOrError, + request: { + ...request, + frameButton, + signerStateActionContext, + method: "POST", + source: "cast-action", + sourceFrame: undefined, + }, + }); + + const actionResponseOrError = await fetchProxied({ + fetchFn, + proxyUrl: frameActionProxy, + frameAction: signedDataOrError, + extraRequestPayload: extraButtonRequestPayload, + }); + + if (actionResponseOrError instanceof Error) { + tryCall(() => { + onError(actionResponseOrError); + }); + throw actionResponseOrError; + } + + // check what is the response, we expect either cast action responses or composer action responses + try { + const endTime = new Date(); + + if (!actionResponseOrError.ok) { + await handleFailedResponse({ + response: actionResponseOrError, + endTime, + frameStackPendingItem: pendingItem, + }); + + return; + } + + const actionResponse = (await actionResponseOrError + .clone() + .json()) as unknown; + + if (isCastMessageResponse(actionResponse)) { + frameStateAPI.markCastMessageAsDone({ + pendingItem, + endTime, + response: actionResponseOrError, + responseData: actionResponse, + }); + return; + } + + if (isCastActionFrameResponse(actionResponse)) { + // this is noop + frameStateAPI.markCastFrameAsDone({ pendingItem, endTime }); + + await fetchPOSTRequest({ + sourceFrame: undefined, + frameButton: { + action: "post", + label: "action", + target: actionResponse.frameUrl, + }, + isDangerousSkipSigning: request.isDangerousSkipSigning, + method: "POST", + source: "cast-action", + signerStateActionContext: { + ...request.signerStateActionContext, + type: "default", + buttonIndex: 1, + frameButton: { + action: "post", + label: "action", + target: actionResponse.frameUrl, + }, + target: actionResponse.frameUrl, + }, + }); + return; + } + + throw new CastActionUnexpectedResponseError(); + } catch (e) { + let error: Error; + + if (!(e instanceof CastActionUnexpectedResponseError)) { + console.error(`Unexpected response from the server`, e); + error = e instanceof Error ? e : new Error("Unexpected error"); + } else { + error = e; + } + + tryCall(() => { + onError(error); + }); + throw error; + } + } + + async function fetchComposerActionRequest( + request: ComposerActionRequest, + shouldClear = false + ): Promise { + const frameButton: FrameButtonPost = { + action: "post", + label: request.action.name, + target: request.action.url, + }; + const signerStateActionContext: SignerStateDefaultActionContext = { + ...request.signerStateActionContext, + type: "default", + frameButton, + state: encodeURIComponent( + JSON.stringify({ + cast: request.composerActionState, + } satisfies ComposerActionStateFromMessage) + ), + }; + const signedDataOrError = await signAndGetFrameActionBodyPayload({ + signerStateActionContext, + signerState: request.signerState, + }); + + if (shouldClear) { + frameStateAPI.clear(); + } + + if (signedDataOrError instanceof Error) { + tryCall(() => { + onError(signedDataOrError); + }); + throw signedDataOrError; + } + + // create pending item but do not dispatch it + const pendingItem = frameStateAPI.createCastOrComposerActionPendingItem({ + action: signedDataOrError, + request: { + ...request, + frameButton, + signerStateActionContext, + method: "POST", + source: "composer-action", + sourceFrame: undefined, + }, + }); + + const actionResponseOrError = await fetchProxied({ + fetchFn, + proxyUrl: frameActionProxy, + frameAction: signedDataOrError, + extraRequestPayload: extraButtonRequestPayload, + }); + + if (actionResponseOrError instanceof Error) { + tryCall(() => { + onError(actionResponseOrError); + }); + throw actionResponseOrError; + } + + // check what is the response, we expect either cast action responses or composer action responses + try { + const endTime = new Date(); + + if (!actionResponseOrError.ok) { + await handleFailedResponse({ + response: actionResponseOrError, + endTime, + frameStackPendingItem: pendingItem, + }); + + return; + } + + const actionResponse = (await actionResponseOrError + .clone() + .json()) as unknown; + + if (!isComposerFormActionResponse(actionResponse)) { + throw new ComposerActionUnexpectedResponseError(); + } + + // this is noop + frameStateAPI.markComposerFormActionAsDone({ pendingItem, endTime }); + + await onComposerFormAction({ + form: actionResponse, + cast: { + embeds: [], + text: "Cast text", + }, + }); + } catch (e) { + let error: Error; + + if (!(e instanceof ComposerActionUnexpectedResponseError)) { + console.error(`Unexpected response from the server`, e); + error = e instanceof Error ? e : new Error("Unexpected error"); + } else { + error = e; + } + + tryCall(() => { + onError(error); + }); + throw error; + } + } + + return (request, shouldClear = false) => { + if (request.method === "GET") { + return fetchGETRequest(request, shouldClear); + } + + if (request.method === "CAST_ACTION") { + return fetchCastActionRequest(request, shouldClear); + } + + if (request.method === "COMPOSER_ACTION") { + return fetchComposerActionRequest(request, shouldClear); + } + + if (request.frameButton.action === "tx") { + return fetchTransactionRequest(request, shouldClear); + } + + return fetchPOSTRequest(request, { + shouldClear, + }); + }; +} + +function proxyUrlAndSearchParamsToUrl( + proxyUrl: string, + ...searchParams: URLSearchParams[] +): string { + const temporaryDomain = "temporary-for-parsing-purposes.tld"; + const parsedProxyUrl = new URL(proxyUrl, `http://${temporaryDomain}`); + + searchParams.forEach((params) => { + params.forEach((value, key) => { + parsedProxyUrl.searchParams.set(key, value); + }); + }); + + return parsedProxyUrl.hostname === temporaryDomain + ? `${parsedProxyUrl.pathname}${parsedProxyUrl.search}` + : parsedProxyUrl.toString(); +} + +type FetchProxiedArg = { + proxyUrl: string; + fetchFn: typeof fetch; +} & ( + | { + frameAction: SignedFrameAction; + extraRequestPayload?: Record; + } + | { url: string } +); + +async function fetchProxied( + params: FetchProxiedArg +): Promise { + const searchParams = new URLSearchParams({ + multispecification: "true", + }); + + if ("frameAction" in params) { + const proxyUrl = proxyUrlAndSearchParamsToUrl( + params.proxyUrl, + searchParams, + params.frameAction.searchParams + ); + + return tryCallAsync(() => + params.fetchFn(proxyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...params.extraRequestPayload, + ...params.frameAction.body, + }), + }) + ); + } + + searchParams.set("url", params.url); + + const proxyUrl = proxyUrlAndSearchParamsToUrl(params.proxyUrl, searchParams); + + return tryCallAsync(() => params.fetchFn(proxyUrl, { method: "GET" })); +} + +function getResponseBody(response: Response): Promise { + if (response.headers.get("content-type")?.includes("/json")) { + return response.clone().json(); + } + + return response.clone().text(); +} + +type SignAndGetFrameActionPayloadOptions = { + signerState: SignerStateInstance; + signerStateActionContext: SignerStateActionContext; +}; + +/** + * This shouldn't be used for transaction data request + */ +async function signAndGetFrameActionBodyPayload({ + signerStateActionContext, + signerState, +}: SignAndGetFrameActionPayloadOptions): Promise { + // Transacting address is not included in post action + const { address: _, ...requiredFrameContext } = + signerStateActionContext.frameContext as unknown as FarcasterFrameContext; + + return tryCallAsync(() => + signerState.signFrameAction({ + ...signerStateActionContext, + frameContext: requiredFrameContext, + }) + ); +} diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts new file mode 100644 index 000000000..247e8ebb5 --- /dev/null +++ b/packages/render/src/unstable-use-frame-state.ts @@ -0,0 +1,553 @@ +import type { MutableRefObject } from "react"; +import { useMemo, useReducer, useRef } from "react"; +import type { + ParseFramesWithReportsResult, + SupportedParsingSpecification, +} from "frames.js/frame-parsers"; +import type { + CastActionMessageResponse, + ErrorMessageResponse, +} from "frames.js/types"; +import type { + FrameContext, + FrameGETRequest, + FramePOSTRequest, + FrameStackGetPending, + FrameStackPostPending, + SignedFrameAction, + SignerStateActionContext, + SignerStateInstance, +} from "./types"; +import type { + FrameReducerActions, + FramesStack, + ResolveSpecificationFunction, +} from "./unstable-types"; +import { useFreshRef } from "./hooks/use-fresh-ref"; + +function computeDurationInSeconds(start: Date, end: Date): number { + return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); +} + +export type FrameState = + | { + type: "initialized"; + stack: FramesStack; + signerState: SignerStateInstance; + frameContext: FrameContext; + specification: SupportedParsingSpecification; + homeframeUrl: string; + } + | { + type: "not-initialized"; + stack: FramesStack; + }; + +function createFramesStackReducer( + resolveSpecificationRef: MutableRefObject +) { + return function framesStackReducer( + state: FrameState, + action: FrameReducerActions + ): FrameState { + switch (action.action) { + case "LOAD": + return { + ...state, + stack: [action.item, ...state.stack], + }; + case "DONE_REDIRECT": { + const index = state.stack.findIndex( + (item) => item.timestamp === action.pendingItem.timestamp + ); + + if (index === -1) { + return state; + } + + state.stack[index] = { + ...action.pendingItem, + ...action.item, + status: "doneRedirect", + }; + + return { + ...state, + stack: state.stack.slice(), + }; + } + case "DONE_WITH_ERROR_MESSAGE": + case "DONE_WITH_MESSAGE": { + const index = state.stack.findIndex( + (item) => item.timestamp === action.pendingItem.timestamp + ); + + if (index === -1) { + return state; + } + + state.stack[index] = { + ...action.pendingItem, + ...action.item, + }; + + return { + ...state, + stack: state.stack.slice(), + }; + } + case "DONE": { + const index = state.stack.findIndex( + (item) => item.timestamp === action.pendingItem.timestamp + ); + + if (index === -1) { + return state; + } + + let signerState: SignerStateInstance; + let specification: SupportedParsingSpecification; + let frameContext: FrameContext; + let homeframeUrl: string; + + if (state.type === "not-initialized") { + /** + * This is a response for initial request in the stack. We don't care if the request was GET or POST + * because we care only about initializing on initial request. + * + * It can be POST if you have a frame cast action response. Then we load the frame by sending a POST request. + */ + const resolvedSpecification = resolveSpecificationRef.current({ + parseResult: action.parseResult, + }); + + ({ signerState, specification, frameContext } = + resolvedSpecification); + homeframeUrl = action.pendingItem.url; + } else { + ({ signerState, specification, frameContext, homeframeUrl } = state); + } + + state.stack[index] = { + ...action.pendingItem, + status: "done", + speed: computeDurationInSeconds( + action.pendingItem.timestamp, + action.endTime + ), + frameResult: action.parseResult[specification], + response: action.response, + responseStatus: action.response.status, + responseBody: action.parseResult, + }; + + return { + ...state, + signerState, + frameContext, + homeframeUrl, + specification, + type: "initialized", + stack: state.stack.slice(), + }; + } + case "REQUEST_ERROR": { + const index = state.stack.findIndex( + (item) => item.timestamp === action.pendingItem.timestamp + ); + + if (index === -1) { + return state; + } + + state.stack[index] = action.item; + + return { + ...state, + stack: state.stack.slice(), + }; + } + case "RESET_INITIAL_FRAME": { + const { frameContext, signerState, specification } = + resolveSpecificationRef.current({ parseResult: action.parseResult }); + const frameResult = action.parseResult[specification]; + + return { + type: "initialized", + signerState, + frameContext, + specification, + homeframeUrl: action.homeframeUrl, + stack: [ + { + request: { + method: "GET", + url: action.homeframeUrl, + }, + url: action.homeframeUrl, + requestDetails: {}, + response: new Response(JSON.stringify(frameResult), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + responseStatus: 200, + timestamp: new Date(), + speed: 0, + frameResult, + status: "done", + responseBody: frameResult, + }, + ], + }; + } + case "CLEAR": + return { + type: "not-initialized", + stack: [], + }; + default: + return state; + } + }; +} + +type UseFrameStateOptions = { + initialParseResult?: ParseFramesWithReportsResult | null; + initialFrameUrl?: string | null; + resolveSpecification: ResolveSpecificationFunction; +}; + +export type FrameStateAPI = { + dispatch: React.Dispatch; + clear: () => void; + createGetPendingItem: (arg: { + request: FrameGETRequest; + }) => FrameStackGetPending; + createPostPendingItem: < + TSignerStateActionContext extends SignerStateActionContext, + >(arg: { + action: SignedFrameAction; + request: FramePOSTRequest; + /** + * Optional, allows to override the start time + * + * @defaultValue new Date() + */ + startTime?: Date; + }) => FrameStackPostPending; + /** + * Creates a pending item without dispatching it + */ + createCastOrComposerActionPendingItem: < + TSignerStateActionContext extends SignerStateActionContext, + >(arg: { + action: SignedFrameAction; + request: FramePOSTRequest; + }) => FrameStackPostPending; + markCastMessageAsDone: (arg: { + pendingItem: FrameStackPostPending; + endTime: Date; + response: Response; + responseData: CastActionMessageResponse; + }) => void; + markCastFrameAsDone: (arg: { + pendingItem: FrameStackPostPending; + endTime: Date; + }) => void; + markComposerFormActionAsDone: (arg: { + pendingItem: FrameStackPostPending; + endTime: Date; + }) => void; + markAsDone: (arg: { + pendingItem: FrameStackGetPending | FrameStackPostPending; + endTime: Date; + response: Response; + parseResult: ParseFramesWithReportsResult; + }) => void; + markAsDoneWithRedirect: (arg: { + pendingItem: FrameStackPostPending; + endTime: Date; + location: string; + response: Response; + responseBody: unknown; + }) => void; + markAsDoneWithErrorMessage: (arg: { + pendingItem: FrameStackPostPending; + endTime: Date; + response: Response; + responseData: ErrorMessageResponse; + }) => void; + markAsFailed: (arg: { + pendingItem: FrameStackGetPending | FrameStackPostPending; + endTime: Date; + requestError: Error; + response: Response | null; + responseBody: unknown; + responseStatus: number; + }) => void; + markAsFailedWithRequestError: (arg: { + endTime: Date; + pendingItem: FrameStackPostPending; + error: Error; + response: Response; + responseBody: unknown; + }) => void; + reset: (arg: { + homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; + }) => void; +}; + +export function useFrameState({ + initialParseResult, + initialFrameUrl, + resolveSpecification, +}: UseFrameStateOptions): [FrameState, FrameStateAPI] { + const resolveSpecificationRef = useFreshRef(resolveSpecification); + const reducerRef = useRef(createFramesStackReducer(resolveSpecificationRef)); + const [state, dispatch] = useReducer( + reducerRef.current, + [initialParseResult, initialFrameUrl] as const, + ([parseResult, frameUrl]): FrameState => { + if (parseResult && frameUrl) { + const { frameContext, signerState, specification } = + resolveSpecification({ + parseResult, + }); + const frameResult = parseResult[specification]; + + return { + type: "initialized", + frameContext, + signerState, + specification, + homeframeUrl: frameUrl, + stack: [ + { + response: new Response(JSON.stringify(frameResult), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + responseStatus: 200, + responseBody: frameResult, + timestamp: new Date(), + requestDetails: {}, + request: { + method: "GET", + url: frameUrl, + }, + speed: 0, + frameResult, + status: "done", + url: frameUrl, + }, + ], + }; + } + + return { + type: "not-initialized", + stack: frameUrl + ? [ + // prevent flash of empty content by adding pending item because initial frame is being loaded + { + method: "GET", + request: { + method: "GET", + url: frameUrl, + }, + url: frameUrl, + requestDetails: {}, + timestamp: new Date(), + status: "pending", + }, + ] + : [], + }; + } + ); + + const api: FrameStateAPI = useMemo(() => { + return { + dispatch, + clear() { + dispatch({ + action: "CLEAR", + }); + }, + createGetPendingItem(arg) { + const item: FrameStackGetPending = { + method: "GET", + request: arg.request, + requestDetails: {}, + url: arg.request.url, + timestamp: new Date(), + status: "pending", + }; + + dispatch({ + action: "LOAD", + item, + }); + + return item; + }, + createPostPendingItem(arg) { + const item: FrameStackPostPending = { + method: "POST", + request: arg.request, + requestDetails: { + body: arg.action.body, + searchParams: arg.action.searchParams, + }, + url: arg.action.searchParams.get("postUrl") ?? "missing postUrl", + timestamp: arg.startTime ?? new Date(), + status: "pending", + }; + + dispatch({ + action: "LOAD", + item, + }); + + return item; + }, + createCastOrComposerActionPendingItem(arg) { + return { + method: "POST", + requestDetails: { + body: arg.action.body, + searchParams: arg.action.searchParams, + }, + request: arg.request, + status: "pending", + timestamp: new Date(), + url: arg.action.searchParams.get("postUrl") ?? "missing postUrl", + } satisfies FrameStackPostPending; + }, + markCastFrameAsDone() { + // noop + }, + markCastMessageAsDone(arg) { + dispatch({ + action: "LOAD", + item: arg.pendingItem, + }); + dispatch({ + action: "DONE_WITH_MESSAGE", + pendingItem: arg.pendingItem, + item: { + ...arg.pendingItem, + status: "message", + message: arg.responseData.message, + response: arg.response.clone(), + responseBody: arg.responseData, + responseStatus: arg.response.status, + speed: computeDurationInSeconds( + arg.pendingItem.timestamp, + arg.endTime + ), + type: "info", + }, + }); + }, + markComposerFormActionAsDone() { + // noop + }, + markAsDone(arg) { + dispatch({ + action: "DONE", + pendingItem: arg.pendingItem, + parseResult: arg.parseResult, + response: arg.response.clone(), + endTime: arg.endTime, + }); + }, + markAsDoneWithErrorMessage(arg) { + dispatch({ + action: "DONE_WITH_ERROR_MESSAGE", + pendingItem: arg.pendingItem, + item: { + ...arg.pendingItem, + responseStatus: arg.response.status, + response: arg.response.clone(), + speed: computeDurationInSeconds( + arg.pendingItem.timestamp, + arg.endTime + ), + status: "message", + type: "error", + message: arg.responseData.message, + responseBody: arg.responseData, + }, + }); + }, + markAsDoneWithRedirect(arg) { + dispatch({ + action: "DONE_REDIRECT", + pendingItem: arg.pendingItem, + item: { + ...arg.pendingItem, + location: arg.location, + response: arg.response.clone(), + responseBody: arg.responseBody, + responseStatus: arg.response.status, + status: "doneRedirect", + speed: computeDurationInSeconds( + arg.pendingItem.timestamp, + arg.endTime + ), + }, + }); + }, + markAsFailed(arg) { + dispatch({ + action: "REQUEST_ERROR", + pendingItem: arg.pendingItem, + item: { + request: arg.pendingItem.request, + requestDetails: arg.pendingItem.requestDetails, + timestamp: arg.pendingItem.timestamp, + url: arg.pendingItem.url, + response: arg.response?.clone() ?? null, + responseStatus: arg.responseStatus, + requestError: arg.requestError, + speed: computeDurationInSeconds( + arg.pendingItem.timestamp, + arg.endTime + ), + status: "requestError", + responseBody: arg.responseBody, + }, + }); + }, + markAsFailedWithRequestError(arg) { + dispatch({ + action: "REQUEST_ERROR", + pendingItem: arg.pendingItem, + item: { + ...arg.pendingItem, + status: "requestError", + requestError: arg.error, + response: arg.response.clone(), + responseStatus: arg.response.status, + responseBody: arg.responseBody, + speed: computeDurationInSeconds( + arg.pendingItem.timestamp, + arg.endTime + ), + }, + }); + }, + reset(arg) { + dispatch({ + action: "RESET_INITIAL_FRAME", + homeframeUrl: arg.homeframeUrl, + parseResult: arg.parseResult, + }); + }, + }; + }, [dispatch]); + + return [state, api]; +} diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx new file mode 100644 index 000000000..b76b3f069 --- /dev/null +++ b/packages/render/src/unstable-use-frame.tsx @@ -0,0 +1,600 @@ +/* eslint-disable @typescript-eslint/require-await -- we expect async functions */ +/* eslint-disable no-console -- provide feedback */ +/* eslint-disable no-alert -- provide feedback */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { + Frame, + FrameButton, + FrameButtonLink, + FrameButtonPost, + FrameButtonTx, + TransactionTargetResponse, +} from "frames.js"; +import type { + OnMintArgs, + OnTransactionArgs, + OnSignatureArgs, + CastActionButtonPressFunction, + ComposerActionButtonPressFunction, +} from "./types"; +import type { UseFrameOptions, UseFrameReturnValue } from "./unstable-types"; +import { useFrameState } from "./unstable-use-frame-state"; +import { useFetchFrame } from "./unstable-use-fetch-frame"; +import { useFreshRef } from "./hooks/use-fresh-ref"; + +function onMintFallback({ target }: OnMintArgs): void { + console.log("Please provide your own onMint function to useFrame() hook."); + + const message = `Mint requested: ${target}`; + + if (typeof window !== "undefined") { + window.alert(message); + } else { + console.log(message); + } +} + +function onConnectWalletFallback(): never { + throw new Error( + "Please implement this function in order to use transactions" + ); +} + +async function onTransactionFallback({ + transactionData, +}: OnTransactionArgs): Promise { + console.log( + "Please provide your own onTransaction function to useFrame() hook." + ); + + const message = `Requesting a transaction on chain with ID ${ + transactionData.chainId + } with the following params: ${JSON.stringify( + transactionData.params, + null, + 2 + )}`; + + if (typeof window !== "undefined") { + window.alert(message); + } else { + console.log(message); + } + + return null; +} + +async function onSignatureFallback({ + signatureData, +}: OnSignatureArgs): Promise { + console.log( + "Please provide your own onSignature function to useFrame() hook." + ); + + const message = `Requesting a signature on chain with ID ${ + signatureData.chainId + } with the following params: ${JSON.stringify( + signatureData.params, + null, + 2 + )}`; + + if (typeof window !== "undefined") { + window.alert(message); + } else { + console.log(message); + } + + return null; +} + +function handleRedirectFallback(location: URL): void { + console.log( + "Please provide your own onRedirect function to useFetchFrame() hook." + ); + + const message = `You are about to be redirected to ${location.toString()}`; + + if (typeof window !== "undefined") { + if (window.confirm(message)) { + window.open(location, "_blank")?.focus(); + } + } else { + console.log(message); + } +} + +function handleLinkButtonClickFallback(button: FrameButtonLink): void { + console.log( + "Please provide your own onLinkButtonClick function to useFrame() hook." + ); + + if (typeof window !== "undefined") { + if (window.confirm(`You are about to be redirected to ${button.target}`)) { + parent.window.open(button.target, "_blank"); + } + } else { + console.log(`Link button with target ${button.target} clicked.`); + } +} + +function defaultComposerFormActionHandler(): Promise { + throw new Error('Please implement your own "onComposerFormAction" handler'); +} + +/** + * Validates a link button target to ensure it is a valid HTTP or HTTPS URL. + * @param target - The target URL to validate. + * @returns True if the target is a valid HTTP or HTTPS URL, otherwise throws an error. + */ +function validateLinkButtonTarget(target: string): boolean { + // check the URL is valid + const locationUrl = new URL(target); + + // Reject non-http(s) URLs + if (locationUrl.protocol !== "http:" && locationUrl.protocol !== "https:") { + throw new Error( + `Redirect location ${locationUrl.toString()} is not a valid HTTP or HTTPS URL.` + ); + } + + return true; +} + +export type { UseFrameReturnValue, UseFrameOptions }; + +export function useFrame({ + homeframeUrl, + onMint = onMintFallback, + onTransaction = onTransactionFallback, + transactionDataSuffix, + onConnectWallet = onConnectWalletFallback, + onSignature = onSignatureFallback, + connectedAddress, + frame, + /** Ex: /frames */ + frameActionProxy, + /** Ex: /frames */ + frameGetProxy, + extraButtonRequestPayload, + resolveSpecification, + resolveCastOrComposerActionSigner, + onError, + onLinkButtonClick = handleLinkButtonClickFallback, + onRedirect = handleRedirectFallback, + fetchFn = (...args) => fetch(...args), + onComposerFormAction = defaultComposerFormActionHandler, + onTransactionDataError, + onTransactionDataStart, + onTransactionDataSuccess, + onTransactionError, + onTransactionProcessingError, + onTransactionProcessingStart, + onTransactionProcessingSuccess, + onTransactionStart, + onTransactionSuccess, +}: UseFrameOptions): UseFrameReturnValue { + const resolveCastOrComposerActionSignerRef = useFreshRef( + resolveCastOrComposerActionSigner + ); + const [inputText, setInputText] = useState(""); + const inputTextRef = useFreshRef(inputText); + const [frameState, frameStateAPI] = useFrameState({ + resolveSpecification, + initialFrameUrl: homeframeUrl, + initialParseResult: frame, + }); + const frameStateRef = useFreshRef(frameState); + + const { + clear: clearFrameState, + dispatch: dispatchFrameState, + reset: resetFrameState, + } = frameStateAPI; + + const fetchFrame = useFetchFrame({ + frameState, + frameStateAPI, + frameActionProxy, + frameGetProxy, + onTransaction, + transactionDataSuffix, + onSignature, + extraButtonRequestPayload, + onError, + fetchFn, + onRedirect, + onComposerFormAction, + onTransactionDataError, + onTransactionDataStart, + onTransactionDataSuccess, + onTransactionError, + onTransactionProcessingError, + onTransactionProcessingStart, + onTransactionProcessingSuccess, + onTransactionStart, + onTransactionSuccess, + }); + + const fetchFrameRef = useFreshRef(fetchFrame); + const onErrorRef = useFreshRef(onError); + + useEffect(() => { + if (!homeframeUrl) { + // if we don't have an url we don't want to show anything + clearFrameState(); + + return; + } + + if (!frame) { + fetchFrameRef + .current( + { + url: homeframeUrl, + method: "GET", + }, + // tell the fetchFrame function to clear the stack because this is called only on initial render + // and there could potentially be a pending object returned from SSR + true + ) + .catch((e) => { + console.error(e); + }); + } else { + resetFrameState({ + homeframeUrl, + parseResult: frame, + }); + } + }, [frame, homeframeUrl, clearFrameState, fetchFrameRef, resetFrameState]); + + const onPostButton = useCallback( + async function onPostButton({ + currentFrame, + buttonIndex, + postInputText, + frameButton, + target, + state, + fetchFrameOverride, + }: { + currentFrame: Frame; + frameButton: FrameButtonPost; + buttonIndex: number; + postInputText: string | undefined; + state?: string; + target: string; + fetchFrameOverride?: typeof fetchFrame; + }): Promise { + const currentState = frameStateRef.current; + + if (currentState.type === "not-initialized") { + const error = new Error( + "Cannot perform post/post_redirect without a frame" + ); + + console.error(`@frames.js/render: ${error.message}`); + onErrorRef.current?.(error); + + return; + } + + if (!currentState.signerState.hasSigner) { + const error = new Error("Missing signer"); + + console.error(`@frames.js/render: ${error.message}`); + onErrorRef.current?.(error); + + return; + } + + const _fetchFrame = fetchFrameOverride ?? fetchFrameRef.current; + + await _fetchFrame({ + frameButton, + isDangerousSkipSigning: false, + method: "POST", + signerStateActionContext: { + inputText: postInputText, + signer: currentState.signerState.signer, + frameContext: currentState.frameContext, + url: currentState.homeframeUrl, + target, + frameButton, + buttonIndex, + state, + }, + sourceFrame: currentFrame, + }); + }, + [fetchFrameRef, frameStateRef, onErrorRef] + ); + + const onConnectWalletRef = useFreshRef(onConnectWallet); + const connectedAddressRef = useFreshRef(connectedAddress); + + const onTransactionButton = useCallback( + async function onTransactionButton({ + currentFrame, + buttonIndex, + postInputText, + frameButton, + }: { + currentFrame: Frame; + frameButton: FrameButtonTx; + buttonIndex: number; + postInputText: string | undefined; + }): Promise { + const state = frameStateRef.current; + + if (state.type === "not-initialized") { + const error = new Error("Cannot perform transaction without a frame"); + + console.error(`@frames.js/render: ${error.message}`); + onErrorRef.current?.(error); + + return; + } + + // Send post request to get calldata + if (!state.signerState.hasSigner) { + const error = new Error("Missing signer"); + + console.error(`@frames.js/render: ${error.message}`); + onErrorRef.current?.(error); + + return; + } + + if (!connectedAddressRef.current) { + try { + onConnectWalletRef.current(); + } catch (e) { + onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); + console.error(`@frames.js/render: ${String(e)}`); + } + + return; + } + + await fetchFrameRef.current({ + frameButton, + isDangerousSkipSigning: false, + method: "POST", + signerStateActionContext: { + type: "tx-data", + inputText: postInputText, + signer: state.signerState.signer, + frameContext: state.frameContext, + address: connectedAddressRef.current, + url: state.homeframeUrl, + target: frameButton.target, + frameButton, + buttonIndex, + state: currentFrame.state, + }, + sourceFrame: currentFrame, + }); + }, + [ + frameStateRef, + connectedAddressRef, + fetchFrameRef, + onErrorRef, + onConnectWalletRef, + ] + ); + + const onButtonPress = useCallback( + async function onButtonPress( + currentFrame: Frame, + frameButton: FrameButton, + index: number, + fetchFrameOverride?: typeof fetchFrame + ): Promise { + switch (frameButton.action) { + case "link": { + try { + validateLinkButtonTarget(frameButton.target); + } catch (error) { + if (error instanceof Error) { + onErrorRef.current?.(error); + } + return; + } + + onLinkButtonClick(frameButton); + break; + } + case "mint": { + onMint({ + frameButton, + target: frameButton.target, + frame: currentFrame, + }); + break; + } + case "tx": { + await onTransactionButton({ + frameButton, + buttonIndex: index + 1, + postInputText: + currentFrame.inputText !== undefined + ? inputTextRef.current + : undefined, + currentFrame, + }); + break; + } + case "post": + case "post_redirect": { + try { + const target = + frameButton.target || + frameButton.post_url || + currentFrame.postUrl || + homeframeUrl; + + if (!target) { + onErrorRef.current?.(new Error(`Missing target`)); + return; + } + + try { + validateLinkButtonTarget(target); + } catch (error) { + if (error instanceof Error) { + onErrorRef.current?.(error); + } + return; + } + + await onPostButton({ + currentFrame, + frameButton, + /** https://docs.farcaster.xyz/reference/frames/spec#handling-clicks + + POST the packet to fc:frame:button:$idx:action:target if present + POST the packet to fc:frame:post_url if target was not present. + POST the packet to or the frame's embed URL if neither target nor action were present. + */ + target, + buttonIndex: index + 1, + postInputText: + currentFrame.inputText !== undefined + ? inputTextRef.current + : undefined, + state: currentFrame.state, + fetchFrameOverride, + }); + setInputText(""); + } catch (err) { + if (err instanceof Error) { + onErrorRef.current?.(err); + } + + console.error(err); + } + break; + } + default: + throw new Error("Unrecognized frame button action"); + } + }, + [ + homeframeUrl, + inputTextRef, + onErrorRef, + onLinkButtonClick, + onMint, + onPostButton, + onTransactionButton, + ] + ); + + const onCastActionButtonPress: CastActionButtonPressFunction = useCallback( + async function onActionButtonPress(arg) { + const { signerState, frameContext } = + resolveCastOrComposerActionSignerRef.current({ + action: { + type: "cast", + action: arg.castAction, + }, + }); + + if (!signerState.hasSigner) { + await signerState.onSignerlessFramePress(); + // don't continue, let the app handle + return; + } + + return fetchFrame( + { + method: "CAST_ACTION", + signerState, + action: arg.castAction, + isDangerousSkipSigning: false, + signerStateActionContext: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- we trust the signerState + signer: signerState.signer, + frameContext, + url: arg.castAction.url, + target: arg.castAction.url, + buttonIndex: 1, + }, + }, + arg.clearStack + ); + }, + [fetchFrame, resolveCastOrComposerActionSignerRef] + ); + + const onComposerActionButtonPress: ComposerActionButtonPressFunction = + useCallback( + async function onActionButtonPress(arg) { + const { signerState, frameContext } = + resolveCastOrComposerActionSignerRef.current({ + action: { + type: "compose", + action: arg.castAction, + composerActionState: arg.composerActionState, + }, + }); + + if (!signerState.hasSigner) { + await signerState.onSignerlessFramePress(); + // don't continue, let the app handle + return; + } + + return fetchFrame( + { + method: "COMPOSER_ACTION", + signerState, + action: arg.castAction, + isDangerousSkipSigning: false, + composerActionState: arg.composerActionState, + signerStateActionContext: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- we trust the signerState + signer: signerState.signer, + frameContext, + url: arg.castAction.url, + target: arg.castAction.url, + buttonIndex: 1, + }, + }, + arg.clearStack + ); + }, + [fetchFrame, resolveCastOrComposerActionSignerRef] + ); + + const { stack } = frameState; + + return useMemo(() => { + return { + inputText, + setInputText, + clearFrameStack: clearFrameState, + dispatchFrameStack: dispatchFrameState, + onButtonPress, + fetchFrame, + homeframeUrl, + framesStack: stack, + currentFrameStackItem: stack[0], + onCastActionButtonPress, + onComposerActionButtonPress, + }; + }, [ + inputText, + clearFrameState, + dispatchFrameState, + onButtonPress, + fetchFrame, + homeframeUrl, + stack, + onCastActionButtonPress, + onComposerActionButtonPress, + ]); +} From 96dc0bebf579a6e43e937a996432df38539d530b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Fri, 25 Oct 2024 09:08:15 +0200 Subject: [PATCH 05/27] feat: support new unstable api in debugger --- .changeset/twenty-pugs-roll.md | 7 + .../app/components/action-debugger.tsx | 13 +- .../debugger/app/components/cast-composer.tsx | 18 +- .../app/components/frame-debugger.tsx | 13 +- packages/debugger/app/debugger-page.tsx | 176 ++++++++++-------- 5 files changed, 118 insertions(+), 109 deletions(-) create mode 100644 .changeset/twenty-pugs-roll.md diff --git a/.changeset/twenty-pugs-roll.md b/.changeset/twenty-pugs-roll.md new file mode 100644 index 000000000..41a0bfcb9 --- /dev/null +++ b/.changeset/twenty-pugs-roll.md @@ -0,0 +1,7 @@ +--- +"frames.js": patch +"@frames.js/debugger": patch +"@frames.js/render": patch +--- + +feat: multi specification support diff --git a/packages/debugger/app/components/action-debugger.tsx b/packages/debugger/app/components/action-debugger.tsx index c6835584b..4e87c98d6 100644 --- a/packages/debugger/app/components/action-debugger.tsx +++ b/packages/debugger/app/components/action-debugger.tsx @@ -7,8 +7,6 @@ import { } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; import { - type FarcasterFrameContext, - type FrameActionBodyPayload, OnComposeFormActionFuncReturnType, defaultTheme, } from "@frames.js/render"; @@ -33,7 +31,6 @@ import { Button } from "../../@/components/ui/button"; import { FrameDebugger } from "./frame-debugger"; import IconByName from "./octicons"; import { MockHubActionContext } from "../utils/mock-hub-utils"; -import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import { useToast } from "@/components/ui/use-toast"; import type { CastActionDefinitionResponse } from "../frames/route"; @@ -42,7 +39,7 @@ import { AwaitableController } from "../lib/awaitable-controller"; import type { ComposerActionFormResponse } from "frames.js/types"; import { CastComposer, CastComposerRef } from "./cast-composer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { useFrame } from "@frames.js/render/unstable-use-frame"; type FrameDebuggerFramePropertiesTableRowsProps = { actionMetadataItem: CastActionDefinitionResponse; @@ -177,13 +174,7 @@ function ShortenedText({ type ActionDebuggerProps = { actionMetadataItem: CastActionDefinitionResponse; - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; + farcasterFrameConfig: Parameters[0]; refreshUrl: (arg0?: string) => void; mockHubContext?: Partial; setMockHubContext?: Dispatch>>; diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 8452fa809..3ee76200f 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -11,7 +11,6 @@ import { ExternalLinkIcon, } from "lucide-react"; import IconByName from "./octicons"; -import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import type { FarcasterFrameContext, @@ -23,17 +22,12 @@ import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { useFrame } from "@frames.js/render/unstable-use-frame"; type CastComposerProps = { composerAction: Partial; onComposerActionClick: (state: ComposerActionState) => any; - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; + farcasterFrameConfig: Parameters[0]; }; export type CastComposerRef = { @@ -119,13 +113,7 @@ export const CastComposer = React.forwardRef< CastComposer.displayName = "CastComposer"; type CastEmbedPreviewProps = { - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; + farcasterFrameConfig: Parameters[0]; url: string; onRemove: () => void; }; diff --git a/packages/debugger/app/components/frame-debugger.tsx b/packages/debugger/app/components/frame-debugger.tsx index dc0018f66..04f8a3041 100644 --- a/packages/debugger/app/components/frame-debugger.tsx +++ b/packages/debugger/app/components/frame-debugger.tsx @@ -63,6 +63,7 @@ import type { AnonymousSigner } from "@frames.js/render/identity/anonymous"; import type { LensSigner } from "@frames.js/render/identity/lens"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; import type { XmtpSigner } from "@frames.js/render/identity/xmtp"; +import type { UseFrameReturnValue } from "@frames.js/render/unstable-use-frame"; type FrameDiagnosticsProps = { stackItem: FramesStackItem; @@ -160,8 +161,8 @@ function FrameDiagnostics({ stackItem }: FrameDiagnosticsProps) { {stackItem.speed > 5 ? `Request took more than 5s (${stackItem.speed} seconds). This may be normal: first request will take longer in development (as next.js builds), but in production, clients will timeout requests after 5s` : stackItem.speed > 4 - ? `Warning: Request took more than 4s (${stackItem.speed} seconds). Requests will fail at 5s. This may be normal: first request will take longer in development (as next.js builds), but in production, if there's variance here, requests could fail in production if over 5s` - : `${stackItem.speed} seconds`} + ? `Warning: Request took more than 4s (${stackItem.speed} seconds). Requests will fail at 5s. This may be normal: first request will take longer in development (as next.js builds), but in production, if there's variance here, requests could fail in production if over 5s` + : `${stackItem.speed} seconds`} {properties.validProperties.map(([propertyKey, value]) => { @@ -282,9 +283,7 @@ const FramesRequestCardContentIcon: React.FC<{ const FramesRequestCardContent: React.FC<{ stack: FramesStack; - fetchFrame: FrameState< - FarcasterSigner | XmtpSigner | LensSigner | AnonymousSigner | null - >["fetchFrame"]; + fetchFrame: UseFrameReturnValue["fetchFrame"]; }> = ({ fetchFrame, stack }) => { return stack.map((frameStackItem, i) => { return ( @@ -369,10 +368,10 @@ type FrameDebuggerSharedProps = { type FrameDebuggerProps = FrameDebuggerSharedProps & ( | { - useFrameHook: () => FrameState; + useFrameHook: () => UseFrameReturnValue; } | { - frameState: FrameState; + frameState: UseFrameReturnValue; } ); diff --git a/packages/debugger/app/debugger-page.tsx b/packages/debugger/app/debugger-page.tsx index 6b1b60bed..7d7fca085 100644 --- a/packages/debugger/app/debugger-page.tsx +++ b/packages/debugger/app/debugger-page.tsx @@ -4,16 +4,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { - type UseFrameOptions, fallbackFrameContext, type OnTransactionFunc, type OnSignatureFunc, - type FrameActionBodyPayload, type OnConnectWalletFunc, - type FarcasterFrameContext, } from "@frames.js/render"; import { attribution } from "@frames.js/render/farcaster"; -import { useFrame } from "@frames.js/render/use-frame"; import { ConnectButton, useConnectModal } from "@rainbow-me/rainbowkit"; import { sendTransaction, signTypedData, switchChain } from "@wagmi/core"; import { useRouter } from "next/navigation"; @@ -39,7 +35,10 @@ import { ActionDebugger, ActionDebuggerRef, } from "./components/action-debugger"; -import type { ParseResult } from "frames.js/frame-parsers"; +import type { + ParseFramesWithReportsResult, + ParseResult, +} from "frames.js/frame-parsers"; import { Loader2 } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@/components/ui/toast"; @@ -66,6 +65,10 @@ import { useXmtpFrameContext, useXmtpIdentity, } from "@frames.js/render/identity/xmtp"; +import { + useFrame, + type UseFrameOptions, +} from "@frames.js/render/unstable-use-frame"; const FALLBACK_URL = process.env.NEXT_PUBLIC_DEBUGGER_DEFAULT_URL || "http://localhost:3000"; @@ -121,7 +124,8 @@ export default function DebuggerPage({ return undefined; } }, [searchParams.url]); - const [initialFrame, setInitialFrame] = useState(); + const [initialFrame, setInitialFrame] = + useState(); const [initialAction, setInitialAction] = useState(); const [mockHubContext, setMockHubContext] = useState< @@ -175,7 +179,6 @@ export default function DebuggerPage({ const searchParams = new URLSearchParams({ url: newUrl || url, - specification: protocolConfiguration.specification, actions: "true", }); const proxiedUrl = `/frames?${searchParams.toString()}`; @@ -197,7 +200,7 @@ export default function DebuggerPage({ setInitialAction(json); setInitialFrame(undefined); } else if (json.type === "frame") { - setInitialFrame(json[protocolConfiguration.specification]); + setInitialFrame(json); setInitialAction(undefined); } }) @@ -299,8 +302,6 @@ export default function DebuggerPage({ }, }); - const anonymousFrameContext = {}; - const onConnectWallet: OnConnectWalletFunc = useCallback(async () => { if (!openConnectModal) { throw new Error(`openConnectModal not implemented`); @@ -425,18 +426,65 @@ export default function DebuggerPage({ [account.address, currentChainId, config, openConnectModal, toast] ); - const useFrameConfig: Omit< - UseFrameOptions, FrameActionBodyPayload>, - "signerState" | "specification" - > = useMemo( + const useFrameConfig: UseFrameOptions = useMemo( () => ({ homeframeUrl: url, frame: initialFrame, frameActionProxy: "/frames", frameGetProxy: "/frames", - frameContext: { - ...fallbackFrameContext, - address: account.address || fallbackFrameContext.address, + resolveCastOrComposerActionSigner() { + console.log("resolve cast or composer action signer"); + + return { + signerState: farcasterSignerState, + frameContext: { + ...farcasterFrameContext.frameContext, + address: + account.address || farcasterFrameContext.frameContext.address, + }, + }; + }, + resolveSpecification() { + console.log("resolve spec"); + if ( + !protocolConfiguration?.protocol || + protocolConfiguration.protocol === "farcaster" + ) { + return { + frameContext: { + ...fallbackFrameContext, + address: account.address || fallbackFrameContext.address, + }, + signerState: farcasterSignerState, + specification: "farcaster", + }; + } + + switch (protocolConfiguration.protocol) { + case "anonymous": { + return { + specification: "openframes", + frameContext: {}, + signerState: anonymousSignerState, + }; + } + case "lens": { + return { + specification: "openframes", + frameContext: lensFrameContext.frameContext, + signerState: lensSignerState, + }; + } + case "xmtp": { + return { + specification: "openframes", + frameContext: xmtpFrameContext.frameContext, + signerState: xmtpSignerState, + }; + } + default: + throw new Error("Unknown protocol"); + } }, connectedAddress: account.address, extraButtonRequestPayload: { mockData: mockHubContext }, @@ -561,32 +609,53 @@ export default function DebuggerPage({ }), [ account.address, + anonymousSignerState, + farcasterFrameContext.frameContext, + farcasterSignerState, initialFrame, + lensFrameContext.frameContext, + lensSignerState, mockHubContext, onConnectWallet, onSignature, onTransaction, openConnectModal, + protocolConfiguration?.protocol, toast, url, + xmtpFrameContext.frameContext, + xmtpSignerState, ] ); - const farcasterFrameConfig: UseFrameOptions< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > = useMemo(() => { + const farcasterFrameConfig: UseFrameOptions = useMemo(() => { const attributionData = process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID ? attribution(parseInt(process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID)) : undefined; return { ...useFrameConfig, - signerState: farcasterSignerState, - specification: "farcaster", - frameContext: { - ...farcasterFrameContext.frameContext, - address: account.address || farcasterFrameContext.frameContext.address, + resolveCastOrComposerActionSigner() { + console.log("resolve cast or composer signer"); + return { + signerState: farcasterSignerState, + frameContext: { + ...farcasterFrameContext.frameContext, + address: + account.address || farcasterFrameContext.frameContext.address, + }, + }; + }, + resolveSpecification() { + console.log("resolve farcaster spec"); + return { + frameContext: { + ...farcasterFrameContext.frameContext, + address: + account.address || farcasterFrameContext.frameContext.address, + }, + signerState: farcasterSignerState, + specification: "farcaster", + }; }, transactionDataSuffix: attributionData, }; @@ -597,55 +666,10 @@ export default function DebuggerPage({ useFrameConfig, ]); - const useFrameHook = useMemo(() => { - return () => { - const selectedProtocol = protocolConfiguration?.protocol ?? "farcaster"; - - switch (selectedProtocol) { - case "anonymous": { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger - return useFrame({ - ...useFrameConfig, - signerState: anonymousSignerState, - specification: "openframes", - frameContext: anonymousFrameContext, - }); - } - case "lens": { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger - return useFrame({ - ...useFrameConfig, - signerState: lensSignerState, - specification: "openframes", - frameContext: lensFrameContext.frameContext, - }); - } - case "xmtp": { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger - return useFrame({ - ...useFrameConfig, - signerState: xmtpSignerState, - specification: "openframes", - frameContext: xmtpFrameContext.frameContext, - }); - } - default: { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger - return useFrame(farcasterFrameConfig); - } - } - }; - }, [ - anonymousSignerState, - anonymousFrameContext, - farcasterFrameConfig, - lensFrameContext.frameContext, - lensSignerState, - protocolConfiguration?.protocol, - useFrameConfig, - xmtpFrameContext.frameContext, - xmtpSignerState, - ]); + const useFrameHook = useCallback(() => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- hook is called in component + return useFrame(useFrameConfig); + }, [useFrameConfig]); return ( From fbbed3136d71c106516b16d422d3bc8a7781ad4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Fri, 25 Oct 2024 10:24:58 +0200 Subject: [PATCH 06/27] feat: add useful properties --- packages/render/package.json | 10 ++++ packages/render/src/helpers.ts | 20 ++++++++ packages/render/src/unstable-types.ts | 23 +++++++-- .../render/src/unstable-use-frame-state.ts | 51 ++++++++++++++++--- packages/render/src/unstable-use-frame.tsx | 10 ++++ 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/packages/render/package.json b/packages/render/package.json index 58ed8d34d..22aa74646 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -46,6 +46,16 @@ "default": "./dist/errors.cjs" } }, + "./helpers": { + "import": { + "types": "./dist/helpers.d.ts", + "default": "./dist/helpers.js" + }, + "require": { + "types": "./dist/helpers.d.cts", + "default": "./dist/helpers.cjs" + } + }, "./next": { "import": { "types": "./dist/next/index.d.ts", diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index c15af1749..3346cc3ba 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -2,6 +2,7 @@ import type { ParseFramesWithReportsResult, ParseResult, } from "frames.js/frame-parsers"; +import type { PartialFrame } from "./ui/types"; export async function tryCallAsync( promiseFn: () => Promise @@ -51,3 +52,22 @@ export function isParseResult(value: unknown): value is ParseResult { !("farcaster" in value) ); } + +export type ParseResultWithPartialFrame = Omit< + Exclude, + "frame" +> & { + frame: PartialFrame; +}; + +// rename +export function isPartialFrame( + value: ParseResult +): value is ParseResultWithPartialFrame { + return ( + value.status === "failure" && + !!value.frame.image && + !!value.frame.buttons && + value.frame.buttons.length > 0 + ); +} diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index ec9b722ac..21f4f79af 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -167,19 +167,31 @@ export type FramesStackItem = | FrameStackMessage; export type UseFrameReturnValue = { + /** + * The signer state is set once it is resolved (on initial frame render) + */ + readonly signerState: SignerStateInstance | undefined; + /** + * The specification is set once it is resolved (on initial frame render) + */ + readonly specification: SupportedParsingSpecification | undefined; fetchFrame: FetchFrameFunction; clearFrameStack: () => void; dispatchFrameStack: Dispatch; /** The frame at the top of the stack (at index 0) */ - currentFrameStackItem: FramesStackItem | undefined; + readonly currentFrameStackItem: FramesStackItem | undefined; /** A stack of frames with additional context, with the most recent frame at index 0 */ - framesStack: FramesStack; - inputText: string; + readonly framesStack: FramesStack; + readonly inputText: string; setInputText: (s: string) => void; onButtonPress: ButtonPressFunction>; - homeframeUrl: string | null | undefined; + readonly homeframeUrl: string | null | undefined; onCastActionButtonPress: CastActionButtonPressFunction; onComposerActionButtonPress: ComposerActionButtonPressFunction; + /** + * Resets the frame state to initial frame and resolves specification and signer again + */ + reset: () => void; }; export type FramesStack = FramesStackItem[]; @@ -217,6 +229,9 @@ export type FrameReducerActions = endTime: Date; } | { action: "CLEAR" } + | { + action: "RESET"; + } | { action: "RESET_INITIAL_FRAME"; parseResult: ParseFramesWithReportsResult; diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts index 247e8ebb5..3c6bf10e8 100644 --- a/packages/render/src/unstable-use-frame-state.ts +++ b/packages/render/src/unstable-use-frame-state.ts @@ -37,6 +37,7 @@ export type FrameState = frameContext: FrameContext; specification: SupportedParsingSpecification; homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; } | { type: "not-initialized"; @@ -109,6 +110,7 @@ function createFramesStackReducer( let specification: SupportedParsingSpecification; let frameContext: FrameContext; let homeframeUrl: string; + let parseResult = action.parseResult; if (state.type === "not-initialized") { /** @@ -125,7 +127,13 @@ function createFramesStackReducer( resolvedSpecification); homeframeUrl = action.pendingItem.url; } else { - ({ signerState, specification, frameContext, homeframeUrl } = state); + ({ + signerState, + specification, + frameContext, + homeframeUrl, + parseResult, + } = state); } state.stack[index] = { @@ -143,6 +151,7 @@ function createFramesStackReducer( return { ...state, + parseResult, signerState, frameContext, homeframeUrl, @@ -167,6 +176,24 @@ function createFramesStackReducer( stack: state.stack.slice(), }; } + case "RESET": { + if (state.type === "not-initialized") { + return state; + } + + const { frameContext, signerState, specification } = + resolveSpecificationRef.current({ parseResult: state.parseResult }); + + return { + ...state, + stack: + !!state.stack[0] && state.stack.length > 0 ? [state.stack[0]] : [], + type: "initialized", + frameContext, + signerState, + specification, + }; + } case "RESET_INITIAL_FRAME": { const { frameContext, signerState, specification } = resolveSpecificationRef.current({ parseResult: action.parseResult }); @@ -178,6 +205,7 @@ function createFramesStackReducer( frameContext, specification, homeframeUrl: action.homeframeUrl, + parseResult: action.parseResult, stack: [ { request: { @@ -292,7 +320,11 @@ export type FrameStateAPI = { response: Response; responseBody: unknown; }) => void; - reset: (arg: { + /** + * If arg is omitted it will reset the frame stack to initial frame and resolves the specification again. + * Otherwise it will set the frame state to provided values and resolve the specification. + */ + reset: (arg?: { homeframeUrl: string; parseResult: ParseFramesWithReportsResult; }) => void; @@ -322,6 +354,7 @@ export function useFrameState({ signerState, specification, homeframeUrl: frameUrl, + parseResult, stack: [ { response: new Response(JSON.stringify(frameResult), { @@ -540,11 +573,15 @@ export function useFrameState({ }); }, reset(arg) { - dispatch({ - action: "RESET_INITIAL_FRAME", - homeframeUrl: arg.homeframeUrl, - parseResult: arg.parseResult, - }); + if (!arg) { + dispatch({ action: "RESET" }); + } else { + dispatch({ + action: "RESET_INITIAL_FRAME", + homeframeUrl: arg.homeframeUrl, + parseResult: arg.parseResult, + }); + } }, }; }, [dispatch]); diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index b76b3f069..8bb1587c9 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -571,13 +571,20 @@ export function useFrame({ ); const { stack } = frameState; + const { signerState, specification } = + frameState.type === "initialized" + ? frameState + : { signerState: undefined, specification: undefined }; return useMemo(() => { return { + signerState, + specification, inputText, setInputText, clearFrameStack: clearFrameState, dispatchFrameStack: dispatchFrameState, + reset: resetFrameState, onButtonPress, fetchFrame, homeframeUrl, @@ -587,10 +594,13 @@ export function useFrame({ onComposerActionButtonPress, }; }, [ + signerState, + specification, inputText, clearFrameState, dispatchFrameState, onButtonPress, + resetFrameState, fetchFrame, homeframeUrl, stack, From d464a3766bb1422be2dc5369420645dfa6f094d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 28 Oct 2024 11:58:47 +0100 Subject: [PATCH 07/27] chore: remove composer and cast actions code --- packages/render/src/unstable-types.ts | 73 +---- .../render/src/unstable-use-fetch-frame.ts | 301 +----------------- .../render/src/unstable-use-frame-state.ts | 73 +---- packages/render/src/unstable-use-frame.tsx | 99 +----- 4 files changed, 6 insertions(+), 540 deletions(-) diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index 21f4f79af..2308d334a 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -11,20 +11,15 @@ import type { ParseResultWithFrameworkDetails, } from "frames.js/frame-parsers"; import type { Dispatch } from "react"; -import type { CastActionResponse, ComposerActionState } from "frames.js/types"; import type { ButtonPressFunction, - CastActionButtonPressFunction, - ComposerActionButtonPressFunction, FrameContext, - FramePOSTRequest, FrameRequest, FrameStackBase, FrameStackDoneRedirect, FrameStackMessage, FrameStackPending, FrameStackRequestError, - OnComposerFormActionFunc, OnConnectWalletFunc, OnMintArgs, OnSignatureFunc, @@ -57,25 +52,6 @@ export type ResolveSpecificationFunction = ( arg: ResolveSpecificationFunctionArg ) => ResolvedSpecification; -export type ResolvedCastOrComposerActionContext = { - signerState: SignerStateInstance; - frameContext: FrameContext; -}; - -export type ResolveCastOrComposerActionContextFunctionArg = { - action: - | { type: "cast"; action: CastActionResponse } - | { - type: "compose"; - action: CastActionResponse; - composerActionState: ComposerActionState; - }; -}; - -export type ResolveCastOrComposerActionContextFunction = ( - arg: ResolveCastOrComposerActionContextFunctionArg -) => ResolvedCastOrComposerActionContext; - export type UseFrameOptions = { /** the route used to POST frame actions. The post_url will be added as a the `url` query parameter */ frameActionProxy: string; @@ -90,10 +66,6 @@ export type UseFrameOptions = { * 3. reset() method on FrameState is called */ resolveSpecification: ResolveSpecificationFunction; - /** - * Called when cast or composer action is required. - */ - resolveCastOrComposerActionSigner: ResolveCastOrComposerActionContextFunction; /** * The url of the homeframe, if null / undefined won't load a frame nor render it. * @@ -139,7 +111,6 @@ export type UseFrameOptions = { UseFetchFrameOptions, | "fetchFn" | "onRedirect" - | "onComposerFormAction" | "onTransactionDataError" | "onTransactionDataStart" | "onTransactionDataSuccess" @@ -186,8 +157,6 @@ export type UseFrameReturnValue = { setInputText: (s: string) => void; onButtonPress: ButtonPressFunction>; readonly homeframeUrl: string | null | undefined; - onCastActionButtonPress: CastActionButtonPressFunction; - onComposerActionButtonPress: ComposerActionButtonPressFunction; /** * Resets the frame state to initial frame and resolves specification and signer again */ @@ -216,11 +185,6 @@ export type FrameReducerActions = pendingItem: FrameStackPending; item: Exclude; } - | { - action: "DONE_WITH_MESSAGE"; - pendingItem: FrameStackPending; - item: Exclude; - } | { action: "DONE"; pendingItem: FrameStackPending; @@ -260,7 +224,6 @@ export type UseFetchFrameOptions = { /** Transaction data suffix */ transactionDataSuffix?: `0x${string}`; onSignature: OnSignatureFunc; - onComposerFormAction: OnComposerFormActionFunc; /** * This function can be used to customize how error is reported to the user. * @@ -346,10 +309,7 @@ export type UseFetchFrameOptions = { }; export type FetchFrameFunction = ( - request: - | FrameRequest - | CastActionRequest - | ComposerActionRequest, + request: FrameRequest, /** * If true, the frame stack will be cleared before the new frame is loaded * @@ -357,34 +317,3 @@ export type FetchFrameFunction = ( */ shouldClear?: boolean ) => Promise; - -export type CastActionRequest = Omit< - FramePOSTRequest, - "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" -> & { - method: "CAST_ACTION"; - signerState: SignerStateInstance; - action: CastActionResponse & { - url: string; - }; - signerStateActionContext: Omit< - FramePOSTRequest["signerStateActionContext"], - "frameButton" | "inputText" | "state" - >; -}; - -export type ComposerActionRequest = Omit< - FramePOSTRequest, - "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" -> & { - method: "COMPOSER_ACTION"; - signerState: SignerStateInstance; - action: CastActionResponse & { - url: string; - }; - composerActionState: ComposerActionState; - signerStateActionContext: Omit< - FramePOSTRequest["signerStateActionContext"], - "frameButton" | "inputText" | "state" - >; -}; diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts index 1ad995565..08c81c6e0 100644 --- a/packages/render/src/unstable-use-fetch-frame.ts +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -1,12 +1,6 @@ /* eslint-disable no-console -- provide feedback to console */ -import type { FrameButtonPost, TransactionTargetResponse } from "frames.js"; -import type { types } from "frames.js/core"; -import type { - CastActionFrameResponse, - ComposerActionFormResponse, - ComposerActionStateFromMessage, - ErrorMessageResponse, -} from "frames.js/types"; +import type { TransactionTargetResponse } from "frames.js"; +import type { ErrorMessageResponse } from "frames.js/types"; import { hexToBytes } from "viem"; import type { FarcasterFrameContext } from "./farcaster"; import { @@ -14,8 +8,6 @@ import { TransactionDataErrorResponseError, TransactionDataTargetMalformedError, TransactionHandlerDidNotReturnTransactionIdError, - CastActionUnexpectedResponseError, - ComposerActionUnexpectedResponseError, } from "./errors"; import { isParseFramesWithReportsResult, @@ -23,8 +15,6 @@ import { tryCallAsync, } from "./helpers"; import type { - CastActionRequest, - ComposerActionRequest, FetchFrameFunction, UseFetchFrameOptions, } from "./unstable-types"; @@ -35,7 +25,6 @@ import type { FrameStackPostPending, SignedFrameAction, SignerStateActionContext, - SignerStateDefaultActionContext, SignerStateInstance, } from "./types"; @@ -50,39 +39,6 @@ function isErrorMessageResponse( ); } -function isComposerFormActionResponse( - response: unknown -): response is ComposerActionFormResponse { - return ( - typeof response === "object" && - response !== null && - "type" in response && - response.type === "form" - ); -} - -function isCastActionFrameResponse( - response: unknown -): response is CastActionFrameResponse { - return ( - typeof response === "object" && - response !== null && - "type" in response && - response.type === "frame" - ); -} - -function isCastMessageResponse( - response: unknown -): response is types.CastActionMessageResponse { - return ( - typeof response === "object" && - response !== null && - "message" in response && - typeof response.message === "string" - ); -} - function defaultErrorHandler(error: Error): void { console.error(error); } @@ -99,7 +55,6 @@ export function useFetchFrame({ onError = defaultErrorHandler, fetchFn, onRedirect, - onComposerFormAction, onTransactionDataError, onTransactionDataStart, onTransactionDataSuccess, @@ -795,263 +750,11 @@ export function useFetchFrame({ ); } - async function fetchCastActionRequest( - request: CastActionRequest, - shouldClear = false - ): Promise { - const frameButton: FrameButtonPost = { - action: "post", - label: request.action.name, - target: request.action.action.postUrl || request.action.url, - }; - const signerStateActionContext: SignerStateDefaultActionContext = { - ...request.signerStateActionContext, - type: "default", - frameButton, - }; - - const signedDataOrError = await signAndGetFrameActionBodyPayload({ - signerStateActionContext, - signerState: request.signerState, - }); - - if (shouldClear) { - frameStateAPI.clear(); - } - - if (signedDataOrError instanceof Error) { - tryCall(() => { - onError(signedDataOrError); - }); - throw signedDataOrError; - } - - // create pending item but do not dispatch it - const pendingItem = frameStateAPI.createCastOrComposerActionPendingItem({ - action: signedDataOrError, - request: { - ...request, - frameButton, - signerStateActionContext, - method: "POST", - source: "cast-action", - sourceFrame: undefined, - }, - }); - - const actionResponseOrError = await fetchProxied({ - fetchFn, - proxyUrl: frameActionProxy, - frameAction: signedDataOrError, - extraRequestPayload: extraButtonRequestPayload, - }); - - if (actionResponseOrError instanceof Error) { - tryCall(() => { - onError(actionResponseOrError); - }); - throw actionResponseOrError; - } - - // check what is the response, we expect either cast action responses or composer action responses - try { - const endTime = new Date(); - - if (!actionResponseOrError.ok) { - await handleFailedResponse({ - response: actionResponseOrError, - endTime, - frameStackPendingItem: pendingItem, - }); - - return; - } - - const actionResponse = (await actionResponseOrError - .clone() - .json()) as unknown; - - if (isCastMessageResponse(actionResponse)) { - frameStateAPI.markCastMessageAsDone({ - pendingItem, - endTime, - response: actionResponseOrError, - responseData: actionResponse, - }); - return; - } - - if (isCastActionFrameResponse(actionResponse)) { - // this is noop - frameStateAPI.markCastFrameAsDone({ pendingItem, endTime }); - - await fetchPOSTRequest({ - sourceFrame: undefined, - frameButton: { - action: "post", - label: "action", - target: actionResponse.frameUrl, - }, - isDangerousSkipSigning: request.isDangerousSkipSigning, - method: "POST", - source: "cast-action", - signerStateActionContext: { - ...request.signerStateActionContext, - type: "default", - buttonIndex: 1, - frameButton: { - action: "post", - label: "action", - target: actionResponse.frameUrl, - }, - target: actionResponse.frameUrl, - }, - }); - return; - } - - throw new CastActionUnexpectedResponseError(); - } catch (e) { - let error: Error; - - if (!(e instanceof CastActionUnexpectedResponseError)) { - console.error(`Unexpected response from the server`, e); - error = e instanceof Error ? e : new Error("Unexpected error"); - } else { - error = e; - } - - tryCall(() => { - onError(error); - }); - throw error; - } - } - - async function fetchComposerActionRequest( - request: ComposerActionRequest, - shouldClear = false - ): Promise { - const frameButton: FrameButtonPost = { - action: "post", - label: request.action.name, - target: request.action.url, - }; - const signerStateActionContext: SignerStateDefaultActionContext = { - ...request.signerStateActionContext, - type: "default", - frameButton, - state: encodeURIComponent( - JSON.stringify({ - cast: request.composerActionState, - } satisfies ComposerActionStateFromMessage) - ), - }; - const signedDataOrError = await signAndGetFrameActionBodyPayload({ - signerStateActionContext, - signerState: request.signerState, - }); - - if (shouldClear) { - frameStateAPI.clear(); - } - - if (signedDataOrError instanceof Error) { - tryCall(() => { - onError(signedDataOrError); - }); - throw signedDataOrError; - } - - // create pending item but do not dispatch it - const pendingItem = frameStateAPI.createCastOrComposerActionPendingItem({ - action: signedDataOrError, - request: { - ...request, - frameButton, - signerStateActionContext, - method: "POST", - source: "composer-action", - sourceFrame: undefined, - }, - }); - - const actionResponseOrError = await fetchProxied({ - fetchFn, - proxyUrl: frameActionProxy, - frameAction: signedDataOrError, - extraRequestPayload: extraButtonRequestPayload, - }); - - if (actionResponseOrError instanceof Error) { - tryCall(() => { - onError(actionResponseOrError); - }); - throw actionResponseOrError; - } - - // check what is the response, we expect either cast action responses or composer action responses - try { - const endTime = new Date(); - - if (!actionResponseOrError.ok) { - await handleFailedResponse({ - response: actionResponseOrError, - endTime, - frameStackPendingItem: pendingItem, - }); - - return; - } - - const actionResponse = (await actionResponseOrError - .clone() - .json()) as unknown; - - if (!isComposerFormActionResponse(actionResponse)) { - throw new ComposerActionUnexpectedResponseError(); - } - - // this is noop - frameStateAPI.markComposerFormActionAsDone({ pendingItem, endTime }); - - await onComposerFormAction({ - form: actionResponse, - cast: { - embeds: [], - text: "Cast text", - }, - }); - } catch (e) { - let error: Error; - - if (!(e instanceof ComposerActionUnexpectedResponseError)) { - console.error(`Unexpected response from the server`, e); - error = e instanceof Error ? e : new Error("Unexpected error"); - } else { - error = e; - } - - tryCall(() => { - onError(error); - }); - throw error; - } - } - return (request, shouldClear = false) => { if (request.method === "GET") { return fetchGETRequest(request, shouldClear); } - if (request.method === "CAST_ACTION") { - return fetchCastActionRequest(request, shouldClear); - } - - if (request.method === "COMPOSER_ACTION") { - return fetchComposerActionRequest(request, shouldClear); - } - if (request.frameButton.action === "tx") { return fetchTransactionRequest(request, shouldClear); } diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts index 3c6bf10e8..e64c3db9e 100644 --- a/packages/render/src/unstable-use-frame-state.ts +++ b/packages/render/src/unstable-use-frame-state.ts @@ -4,10 +4,7 @@ import type { ParseFramesWithReportsResult, SupportedParsingSpecification, } from "frames.js/frame-parsers"; -import type { - CastActionMessageResponse, - ErrorMessageResponse, -} from "frames.js/types"; +import type { ErrorMessageResponse } from "frames.js/types"; import type { FrameContext, FrameGETRequest, @@ -77,8 +74,7 @@ function createFramesStackReducer( stack: state.stack.slice(), }; } - case "DONE_WITH_ERROR_MESSAGE": - case "DONE_WITH_MESSAGE": { + case "DONE_WITH_ERROR_MESSAGE": { const index = state.stack.findIndex( (item) => item.timestamp === action.pendingItem.timestamp ); @@ -263,29 +259,6 @@ export type FrameStateAPI = { */ startTime?: Date; }) => FrameStackPostPending; - /** - * Creates a pending item without dispatching it - */ - createCastOrComposerActionPendingItem: < - TSignerStateActionContext extends SignerStateActionContext, - >(arg: { - action: SignedFrameAction; - request: FramePOSTRequest; - }) => FrameStackPostPending; - markCastMessageAsDone: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - response: Response; - responseData: CastActionMessageResponse; - }) => void; - markCastFrameAsDone: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - }) => void; - markComposerFormActionAsDone: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - }) => void; markAsDone: (arg: { pendingItem: FrameStackGetPending | FrameStackPostPending; endTime: Date; @@ -445,48 +418,6 @@ export function useFrameState({ return item; }, - createCastOrComposerActionPendingItem(arg) { - return { - method: "POST", - requestDetails: { - body: arg.action.body, - searchParams: arg.action.searchParams, - }, - request: arg.request, - status: "pending", - timestamp: new Date(), - url: arg.action.searchParams.get("postUrl") ?? "missing postUrl", - } satisfies FrameStackPostPending; - }, - markCastFrameAsDone() { - // noop - }, - markCastMessageAsDone(arg) { - dispatch({ - action: "LOAD", - item: arg.pendingItem, - }); - dispatch({ - action: "DONE_WITH_MESSAGE", - pendingItem: arg.pendingItem, - item: { - ...arg.pendingItem, - status: "message", - message: arg.responseData.message, - response: arg.response.clone(), - responseBody: arg.responseData, - responseStatus: arg.response.status, - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), - type: "info", - }, - }); - }, - markComposerFormActionAsDone() { - // noop - }, markAsDone(arg) { dispatch({ action: "DONE", diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 8bb1587c9..942c1d11a 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -10,13 +10,7 @@ import type { FrameButtonTx, TransactionTargetResponse, } from "frames.js"; -import type { - OnMintArgs, - OnTransactionArgs, - OnSignatureArgs, - CastActionButtonPressFunction, - ComposerActionButtonPressFunction, -} from "./types"; +import type { OnMintArgs, OnTransactionArgs, OnSignatureArgs } from "./types"; import type { UseFrameOptions, UseFrameReturnValue } from "./unstable-types"; import { useFrameState } from "./unstable-use-frame-state"; import { useFetchFrame } from "./unstable-use-fetch-frame"; @@ -118,10 +112,6 @@ function handleLinkButtonClickFallback(button: FrameButtonLink): void { } } -function defaultComposerFormActionHandler(): Promise { - throw new Error('Please implement your own "onComposerFormAction" handler'); -} - /** * Validates a link button target to ensure it is a valid HTTP or HTTPS URL. * @param target - The target URL to validate. @@ -158,12 +148,10 @@ export function useFrame({ frameGetProxy, extraButtonRequestPayload, resolveSpecification, - resolveCastOrComposerActionSigner, onError, onLinkButtonClick = handleLinkButtonClickFallback, onRedirect = handleRedirectFallback, fetchFn = (...args) => fetch(...args), - onComposerFormAction = defaultComposerFormActionHandler, onTransactionDataError, onTransactionDataStart, onTransactionDataSuccess, @@ -174,9 +162,6 @@ export function useFrame({ onTransactionStart, onTransactionSuccess, }: UseFrameOptions): UseFrameReturnValue { - const resolveCastOrComposerActionSignerRef = useFreshRef( - resolveCastOrComposerActionSigner - ); const [inputText, setInputText] = useState(""); const inputTextRef = useFreshRef(inputText); const [frameState, frameStateAPI] = useFrameState({ @@ -204,7 +189,6 @@ export function useFrame({ onError, fetchFn, onRedirect, - onComposerFormAction, onTransactionDataError, onTransactionDataStart, onTransactionDataSuccess, @@ -493,83 +477,6 @@ export function useFrame({ ] ); - const onCastActionButtonPress: CastActionButtonPressFunction = useCallback( - async function onActionButtonPress(arg) { - const { signerState, frameContext } = - resolveCastOrComposerActionSignerRef.current({ - action: { - type: "cast", - action: arg.castAction, - }, - }); - - if (!signerState.hasSigner) { - await signerState.onSignerlessFramePress(); - // don't continue, let the app handle - return; - } - - return fetchFrame( - { - method: "CAST_ACTION", - signerState, - action: arg.castAction, - isDangerousSkipSigning: false, - signerStateActionContext: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- we trust the signerState - signer: signerState.signer, - frameContext, - url: arg.castAction.url, - target: arg.castAction.url, - buttonIndex: 1, - }, - }, - arg.clearStack - ); - }, - [fetchFrame, resolveCastOrComposerActionSignerRef] - ); - - const onComposerActionButtonPress: ComposerActionButtonPressFunction = - useCallback( - async function onActionButtonPress(arg) { - const { signerState, frameContext } = - resolveCastOrComposerActionSignerRef.current({ - action: { - type: "compose", - action: arg.castAction, - composerActionState: arg.composerActionState, - }, - }); - - if (!signerState.hasSigner) { - await signerState.onSignerlessFramePress(); - // don't continue, let the app handle - return; - } - - return fetchFrame( - { - method: "COMPOSER_ACTION", - signerState, - action: arg.castAction, - isDangerousSkipSigning: false, - composerActionState: arg.composerActionState, - signerStateActionContext: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- we trust the signerState - signer: signerState.signer, - frameContext, - url: arg.castAction.url, - target: arg.castAction.url, - buttonIndex: 1, - }, - }, - arg.clearStack - ); - }, - [fetchFrame, resolveCastOrComposerActionSignerRef] - ); - const { stack } = frameState; const { signerState, specification } = frameState.type === "initialized" @@ -590,8 +497,6 @@ export function useFrame({ homeframeUrl, framesStack: stack, currentFrameStackItem: stack[0], - onCastActionButtonPress, - onComposerActionButtonPress, }; }, [ signerState, @@ -604,7 +509,5 @@ export function useFrame({ fetchFrame, homeframeUrl, stack, - onCastActionButtonPress, - onComposerActionButtonPress, ]); } From e4f0e8e72a9060b9de2cb40cfac4c6f5fb949a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 28 Oct 2024 12:54:28 +0100 Subject: [PATCH 08/27] refactor: use resolveSigner function --- packages/render/src/fallback-frame-context.ts | 10 +-- packages/render/src/farcaster/types.ts | 6 +- .../anonymous/use-anonymous-identity.tsx | 87 ++++++++++--------- .../farcaster/use-farcaster-context.tsx | 7 ++ .../farcaster/use-farcaster-identity.tsx | 7 ++ .../use-farcaster-multi-identity.tsx | 7 ++ .../src/identity/lens/use-lens-identity.tsx | 7 ++ .../src/identity/xmtp/use-xmtp-identity.tsx | 7 ++ packages/render/src/types.ts | 12 +++ packages/render/src/unstable-types.ts | 18 ++-- .../render/src/unstable-use-fetch-frame.ts | 12 +-- .../render/src/unstable-use-frame-state.ts | 39 +++++---- packages/render/src/unstable-use-frame.tsx | 14 ++- 13 files changed, 144 insertions(+), 89 deletions(-) diff --git a/packages/render/src/fallback-frame-context.ts b/packages/render/src/fallback-frame-context.ts index 2c9f5b76a..7ba5a77dc 100644 --- a/packages/render/src/fallback-frame-context.ts +++ b/packages/render/src/fallback-frame-context.ts @@ -1,9 +1 @@ -import type { FarcasterFrameContext } from "./farcaster/types"; - -export const fallbackFrameContext: FarcasterFrameContext = { - castId: { - fid: 1, - hash: "0x0000000000000000000000000000000000000000" as const, - }, - address: "0x0000000000000000000000000000000000000001", -}; +export { fallbackFrameContext } from "./identity/farcaster/use-farcaster-context"; diff --git a/packages/render/src/farcaster/types.ts b/packages/render/src/farcaster/types.ts index cce4d8425..faa238842 100644 --- a/packages/render/src/farcaster/types.ts +++ b/packages/render/src/farcaster/types.ts @@ -1,5 +1,9 @@ export type FarcasterFrameContext = { - /** Connected address of user, only sent with transaction data request */ + /** + * Connected address of user, only sent with transaction data request. + * + * @deprecated - not used + */ address?: `0x${string}`; castId: { hash: `0x${string}`; fid: number }; }; diff --git a/packages/render/src/identity/anonymous/use-anonymous-identity.tsx b/packages/render/src/identity/anonymous/use-anonymous-identity.tsx index 6c9f41afb..c1f1eb2eb 100644 --- a/packages/render/src/identity/anonymous/use-anonymous-identity.tsx +++ b/packages/render/src/identity/anonymous/use-anonymous-identity.tsx @@ -1,5 +1,5 @@ import type { AnonymousOpenFramesRequest } from "frames.js/anonymous"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import type { SignerStateActionContext, SignerStateInstance, @@ -22,53 +22,58 @@ export type AnonymousSignerInstance = SignerStateInstance< const onSignerlessFramePress = (): Promise => Promise.resolve(); const logout = (): Promise => Promise.resolve(); const signer: AnonymousSigner = {}; +const signFrameAction: SignFrameActionFunction< + SignerStateActionContext, + AnonymousOpenFramesRequest +> = (actionContext) => { + const searchParams = new URLSearchParams({ + postType: + actionContext.type === "tx-post" + ? "post" + : actionContext.frameButton.action, + postUrl: actionContext.target ?? "", + specification: "openframes", + }); -export function useAnonymousIdentity(): AnonymousSignerInstance { - const signFrameAction: SignFrameActionFunction< - SignerStateActionContext, - AnonymousOpenFramesRequest - > = useCallback((actionContext) => { - const searchParams = new URLSearchParams({ - postType: - actionContext.type === "tx-post" - ? "post" - : actionContext.frameButton.action, - postUrl: actionContext.target ?? "", - specification: "openframes", - }); - - return Promise.resolve({ - body: { - untrustedData: { - buttonIndex: actionContext.buttonIndex, - state: actionContext.state, - url: actionContext.url, - inputText: actionContext.inputText, - address: - actionContext.type === "tx-data" || actionContext.type === "tx-post" - ? actionContext.address - : undefined, - transactionId: - actionContext.type === "tx-post" - ? actionContext.transactionId - : undefined, - unixTimestamp: Date.now(), - }, - clientProtocol: "anonymous@1.0", + return Promise.resolve({ + body: { + untrustedData: { + buttonIndex: actionContext.buttonIndex, + state: actionContext.state, + url: actionContext.url, + inputText: actionContext.inputText, + address: + actionContext.type === "tx-data" || actionContext.type === "tx-post" + ? actionContext.address + : undefined, + transactionId: + actionContext.type === "tx-post" + ? actionContext.transactionId + : undefined, + unixTimestamp: Date.now(), }, - searchParams, - }); - }, []); + clientProtocol: "anonymous@1.0", + }, + searchParams, + }); +}; - return useMemo( - () => ({ +export function useAnonymousIdentity(): AnonymousSignerInstance { + return useMemo(() => { + return { + specification: "openframes", hasSigner: true, onSignerlessFramePress, signer, isLoadingSigner: false, logout, signFrameAction, - }), - [signFrameAction] - ); + withContext(frameContext) { + return { + signerState: this, + frameContext, + }; + }, + }; + }, []); } diff --git a/packages/render/src/identity/farcaster/use-farcaster-context.tsx b/packages/render/src/identity/farcaster/use-farcaster-context.tsx index 8f7707653..f6abbb69c 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-context.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-context.tsx @@ -5,3 +5,10 @@ export const useFarcasterFrameContext = createFrameContextHook({ storageKey: "farcasterFrameContext", }); + +export const fallbackFrameContext: FarcasterFrameContext = { + castId: { + fid: 1, + hash: "0x0000000000000000000000000000000000000000" as const, + }, +}; diff --git a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx index a24b853f1..a3bcb0226 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx @@ -422,6 +422,7 @@ export function useFarcasterIdentity({ return useMemo( () => ({ + specification: "farcaster", signer: farcasterUser, hasSigner: farcasterUser?.status === "approved" || @@ -433,6 +434,12 @@ export function useFarcasterIdentity({ createSigner, logout, identityPoller, + withContext(frameContext) { + return { + signerState: this, + frameContext, + }; + }, }), [ farcasterUser, 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 4056150e7..520792e74 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx @@ -528,6 +528,7 @@ export function useFarcasterMultiIdentity({ return useMemo( () => ({ + specification: "openframes", signer: farcasterUser, hasSigner: farcasterUser?.status === "approved" || @@ -542,6 +543,12 @@ export function useFarcasterMultiIdentity({ identities: state.identities, selectIdentity, identityPoller, + withContext(frameContext) { + return { + signerState: this, + frameContext, + }; + }, }), [ farcasterUser, diff --git a/packages/render/src/identity/lens/use-lens-identity.tsx b/packages/render/src/identity/lens/use-lens-identity.tsx index 8fc1cc365..468d703dc 100644 --- a/packages/render/src/identity/lens/use-lens-identity.tsx +++ b/packages/render/src/identity/lens/use-lens-identity.tsx @@ -321,6 +321,7 @@ export function useLensIdentity({ return useMemo( () => ({ + specification: "openframes", signer: lensSigner, hasSigner: !!lensSigner?.accessToken, signFrameAction, @@ -331,6 +332,12 @@ export function useLensIdentity({ closeProfileSelector, availableProfiles, handleSelectProfile, + withContext(frameContext) { + return { + signerState: this, + frameContext, + }; + }, }), [ availableProfiles, diff --git a/packages/render/src/identity/xmtp/use-xmtp-identity.tsx b/packages/render/src/identity/xmtp/use-xmtp-identity.tsx index 71b1471d9..7f0950648 100644 --- a/packages/render/src/identity/xmtp/use-xmtp-identity.tsx +++ b/packages/render/src/identity/xmtp/use-xmtp-identity.tsx @@ -210,12 +210,19 @@ export function useXmtpIdentity({ return useMemo( () => ({ + specification: "openframes", signer: xmtpSigner, hasSigner: !!xmtpSigner?.keys, signFrameAction, isLoadingSigner: isLoading, onSignerlessFramePress, logout, + withContext(frameContext) { + return { + signerState: this, + frameContext, + }; + }, }), [isLoading, logout, onSignerlessFramePress, signFrameAction, xmtpSigner] ); diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts index cfa6a3d6d..b85c79107 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -366,6 +366,10 @@ export interface SignerStateInstance< TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, TFrameContextType extends FrameContext = FrameContext, > { + /** + * For which specification is this signer required. + */ + readonly specification: SupportedParsingSpecification; signer: TSignerStorageType | null; /** * True only if signer is approved or impersonating @@ -380,6 +384,14 @@ export interface SignerStateInstance< /** A function called when a frame button is clicked without a signer */ onSignerlessFramePress: () => Promise; logout: () => Promise; + withContext: (context: TFrameContextType) => { + signerState: SignerStateInstance< + TSignerStorageType, + TFrameActionBodyType, + TFrameContextType + >; + frameContext: TFrameContextType; + }; } export type FrameGETRequest = { diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index 2308d334a..8e3d62072 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -29,11 +29,7 @@ import type { } from "./types"; import type { FrameState, FrameStateAPI } from "./unstable-use-frame-state"; -type ResolvedSpecification = { - /** - * Specification that should be used to render the frame. - */ - specification: SupportedParsingSpecification; +export type ResolvedSigner = { /** * Signer that will be used to sign all actions that require signers. */ @@ -41,16 +37,16 @@ type ResolvedSpecification = { /** * The context of this frame, used for generating Frame Action payloads */ - frameContext: FrameContext; + frameContext?: FrameContext; }; -export type ResolveSpecificationFunctionArg = { +export type ResolveSignerFunctionArg = { parseResult: ParseFramesWithReportsResult; }; -export type ResolveSpecificationFunction = ( - arg: ResolveSpecificationFunctionArg -) => ResolvedSpecification; +export type ResolveSignerFunction = ( + arg: ResolveSignerFunctionArg +) => ResolvedSigner; export type UseFrameOptions = { /** the route used to POST frame actions. The post_url will be added as a the `url` query parameter */ @@ -65,7 +61,7 @@ export type UseFrameOptions = { * 2. homeframeUrl changes * 3. reset() method on FrameState is called */ - resolveSpecification: ResolveSpecificationFunction; + resolveSigner: ResolveSignerFunction; /** * The url of the homeframe, if null / undefined won't load a frame nor render it. * diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts index 08c81c6e0..d99c7d651 100644 --- a/packages/render/src/unstable-use-fetch-frame.ts +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -249,7 +249,7 @@ export function useFetchFrame({ // get rid of address from request.signerStateActionContext.frameContext and pass that to sign frame action const signedDataOrError = await signAndGetFrameActionBodyPayload({ - signerStateActionContext: request.signerStateActionContext, + request, signerState: frameState.signerState, }); @@ -838,24 +838,24 @@ function getResponseBody(response: Response): Promise { } type SignAndGetFrameActionPayloadOptions = { + request: FramePOSTRequest; signerState: SignerStateInstance; - signerStateActionContext: SignerStateActionContext; }; /** * This shouldn't be used for transaction data request */ async function signAndGetFrameActionBodyPayload({ - signerStateActionContext, + request, signerState, }: SignAndGetFrameActionPayloadOptions): Promise { // Transacting address is not included in post action - const { address: _, ...requiredFrameContext } = - signerStateActionContext.frameContext as unknown as FarcasterFrameContext; + const { address: _, ...requiredFrameContext } = request + .signerStateActionContext.frameContext as unknown as FarcasterFrameContext; return tryCallAsync(() => signerState.signFrameAction({ - ...signerStateActionContext, + ...request.signerStateActionContext, frameContext: requiredFrameContext, }) ); diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts index e64c3db9e..464a6e245 100644 --- a/packages/render/src/unstable-use-frame-state.ts +++ b/packages/render/src/unstable-use-frame-state.ts @@ -18,7 +18,7 @@ import type { import type { FrameReducerActions, FramesStack, - ResolveSpecificationFunction, + ResolveSignerFunction, } from "./unstable-types"; import { useFreshRef } from "./hooks/use-fresh-ref"; @@ -42,7 +42,7 @@ export type FrameState = }; function createFramesStackReducer( - resolveSpecificationRef: MutableRefObject + resolveSignerRef: MutableRefObject ) { return function framesStackReducer( state: FrameState, @@ -115,13 +115,13 @@ function createFramesStackReducer( * * It can be POST if you have a frame cast action response. Then we load the frame by sending a POST request. */ - const resolvedSpecification = resolveSpecificationRef.current({ + const resolvedSigner = resolveSignerRef.current({ parseResult: action.parseResult, }); - ({ signerState, specification, frameContext } = - resolvedSpecification); + ({ signerState, frameContext = {} } = resolvedSigner); homeframeUrl = action.pendingItem.url; + specification = signerState.specification; } else { ({ signerState, @@ -177,8 +177,9 @@ function createFramesStackReducer( return state; } - const { frameContext, signerState, specification } = - resolveSpecificationRef.current({ parseResult: state.parseResult }); + const { frameContext = {}, signerState } = resolveSignerRef.current({ + parseResult: state.parseResult, + }); return { ...state, @@ -187,19 +188,20 @@ function createFramesStackReducer( type: "initialized", frameContext, signerState, - specification, + specification: signerState.specification, }; } case "RESET_INITIAL_FRAME": { - const { frameContext, signerState, specification } = - resolveSpecificationRef.current({ parseResult: action.parseResult }); - const frameResult = action.parseResult[specification]; + const { frameContext = {}, signerState } = resolveSignerRef.current({ + parseResult: action.parseResult, + }); + const frameResult = action.parseResult[signerState.specification]; return { type: "initialized", signerState, frameContext, - specification, + specification: signerState.specification, homeframeUrl: action.homeframeUrl, parseResult: action.parseResult, stack: [ @@ -238,7 +240,7 @@ function createFramesStackReducer( type UseFrameStateOptions = { initialParseResult?: ParseFramesWithReportsResult | null; initialFrameUrl?: string | null; - resolveSpecification: ResolveSpecificationFunction; + resolveSpecification: ResolveSignerFunction; }; export type FrameStateAPI = { @@ -315,17 +317,16 @@ export function useFrameState({ [initialParseResult, initialFrameUrl] as const, ([parseResult, frameUrl]): FrameState => { if (parseResult && frameUrl) { - const { frameContext, signerState, specification } = - resolveSpecification({ - parseResult, - }); - const frameResult = parseResult[specification]; + const { frameContext = {}, signerState } = resolveSpecification({ + parseResult, + }); + const frameResult = parseResult[signerState.specification]; return { type: "initialized", frameContext, signerState, - specification, + specification: signerState.specification, homeframeUrl: frameUrl, parseResult, stack: [ diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 942c1d11a..b8f2b0717 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -147,7 +147,7 @@ export function useFrame({ /** Ex: /frames */ frameGetProxy, extraButtonRequestPayload, - resolveSpecification, + resolveSigner: resolveSpecification, onError, onLinkButtonClick = handleLinkButtonClickFallback, onRedirect = handleRedirectFallback, @@ -342,6 +342,16 @@ export function useFrame({ return; } + // transaction request always requires address + const frameContext = { + ...state.frameContext, + address: + "address" in state.frameContext && + typeof state.frameContext.address === "string" + ? state.frameContext.address + : connectedAddressRef.current, + }; + await fetchFrameRef.current({ frameButton, isDangerousSkipSigning: false, @@ -350,7 +360,7 @@ export function useFrame({ type: "tx-data", inputText: postInputText, signer: state.signerState.signer, - frameContext: state.frameContext, + frameContext, address: connectedAddressRef.current, url: state.homeframeUrl, target: frameButton.target, From cb3a10d6b22b1fef54fb5ebe3dd1f60315ada34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 28 Oct 2024 13:25:55 +0100 Subject: [PATCH 09/27] chore: export unstable hook from use-frame --- packages/render/src/unstable-use-frame.tsx | 8 ++++++-- packages/render/src/use-frame.tsx | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index b8f2b0717..612ccd9d5 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -131,9 +131,13 @@ function validateLinkButtonTarget(target: string): boolean { return true; } -export type { UseFrameReturnValue, UseFrameOptions }; +export type { + UseFrameReturnValue as UnstableUseFrameReturnValue, + UseFrameOptions as UnstableUseFrameOptions, +}; -export function useFrame({ +// eslint-disable-next-line camelcase -- this is only temporary +export function useFrame_unstable({ homeframeUrl, onMint = onMintFallback, onTransaction = onTransactionFallback, diff --git a/packages/render/src/use-frame.tsx b/packages/render/src/use-frame.tsx index 29e97268a..dedafe433 100644 --- a/packages/render/src/use-frame.tsx +++ b/packages/render/src/use-frame.tsx @@ -26,6 +26,13 @@ import { useFrameStack } from "./use-frame-stack"; import { useFetchFrame } from "./use-fetch-frame"; import { useFreshRef } from "./hooks/use-fresh-ref"; +// eslint-disable-next-line camelcase -- this is only temporary +export { useFrame_unstable } from "./unstable-use-frame"; +export type { + UnstableUseFrameOptions, + UnstableUseFrameReturnValue, +} from "./unstable-use-frame"; + function onMintFallback({ target }: OnMintArgs): void { console.log("Please provide your own onMint function to useFrame() hook."); From 36f5d1fc1c18264e8b68fb01ca3ed4b19db99e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 28 Oct 2024 14:02:36 +0100 Subject: [PATCH 10/27] revert: debugger --- .../app/components/action-debugger.tsx | 13 +- .../debugger/app/components/cast-composer.tsx | 18 +- .../app/components/frame-debugger.tsx | 13 +- packages/debugger/app/debugger-page.tsx | 179 ++++++++---------- packages/debugger/app/frames/route.ts | 25 ++- 5 files changed, 128 insertions(+), 120 deletions(-) diff --git a/packages/debugger/app/components/action-debugger.tsx b/packages/debugger/app/components/action-debugger.tsx index 4e87c98d6..c6835584b 100644 --- a/packages/debugger/app/components/action-debugger.tsx +++ b/packages/debugger/app/components/action-debugger.tsx @@ -7,6 +7,8 @@ import { } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; import { + type FarcasterFrameContext, + type FrameActionBodyPayload, OnComposeFormActionFuncReturnType, defaultTheme, } from "@frames.js/render"; @@ -31,6 +33,7 @@ import { Button } from "../../@/components/ui/button"; import { FrameDebugger } from "./frame-debugger"; import IconByName from "./octicons"; import { MockHubActionContext } from "../utils/mock-hub-utils"; +import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import { useToast } from "@/components/ui/use-toast"; import type { CastActionDefinitionResponse } from "../frames/route"; @@ -39,7 +42,7 @@ import { AwaitableController } from "../lib/awaitable-controller"; import type { ComposerActionFormResponse } from "frames.js/types"; import { CastComposer, CastComposerRef } from "./cast-composer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useFrame } from "@frames.js/render/unstable-use-frame"; +import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; type FrameDebuggerFramePropertiesTableRowsProps = { actionMetadataItem: CastActionDefinitionResponse; @@ -174,7 +177,13 @@ function ShortenedText({ type ActionDebuggerProps = { actionMetadataItem: CastActionDefinitionResponse; - farcasterFrameConfig: Parameters[0]; + farcasterFrameConfig: Parameters< + typeof useFrame< + FarcasterSigner | null, + FrameActionBodyPayload, + FarcasterFrameContext + > + >[0]; refreshUrl: (arg0?: string) => void; mockHubContext?: Partial; setMockHubContext?: Dispatch>>; diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 3ee76200f..8452fa809 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -11,6 +11,7 @@ import { ExternalLinkIcon, } from "lucide-react"; import IconByName from "./octicons"; +import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import type { FarcasterFrameContext, @@ -22,12 +23,17 @@ import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; -import { useFrame } from "@frames.js/render/unstable-use-frame"; type CastComposerProps = { composerAction: Partial; onComposerActionClick: (state: ComposerActionState) => any; - farcasterFrameConfig: Parameters[0]; + farcasterFrameConfig: Parameters< + typeof useFrame< + FarcasterSigner | null, + FrameActionBodyPayload, + FarcasterFrameContext + > + >[0]; }; export type CastComposerRef = { @@ -113,7 +119,13 @@ export const CastComposer = React.forwardRef< CastComposer.displayName = "CastComposer"; type CastEmbedPreviewProps = { - farcasterFrameConfig: Parameters[0]; + farcasterFrameConfig: Parameters< + typeof useFrame< + FarcasterSigner | null, + FrameActionBodyPayload, + FarcasterFrameContext + > + >[0]; url: string; onRemove: () => void; }; diff --git a/packages/debugger/app/components/frame-debugger.tsx b/packages/debugger/app/components/frame-debugger.tsx index 04f8a3041..dc0018f66 100644 --- a/packages/debugger/app/components/frame-debugger.tsx +++ b/packages/debugger/app/components/frame-debugger.tsx @@ -63,7 +63,6 @@ import type { AnonymousSigner } from "@frames.js/render/identity/anonymous"; import type { LensSigner } from "@frames.js/render/identity/lens"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; import type { XmtpSigner } from "@frames.js/render/identity/xmtp"; -import type { UseFrameReturnValue } from "@frames.js/render/unstable-use-frame"; type FrameDiagnosticsProps = { stackItem: FramesStackItem; @@ -161,8 +160,8 @@ function FrameDiagnostics({ stackItem }: FrameDiagnosticsProps) { {stackItem.speed > 5 ? `Request took more than 5s (${stackItem.speed} seconds). This may be normal: first request will take longer in development (as next.js builds), but in production, clients will timeout requests after 5s` : stackItem.speed > 4 - ? `Warning: Request took more than 4s (${stackItem.speed} seconds). Requests will fail at 5s. This may be normal: first request will take longer in development (as next.js builds), but in production, if there's variance here, requests could fail in production if over 5s` - : `${stackItem.speed} seconds`} + ? `Warning: Request took more than 4s (${stackItem.speed} seconds). Requests will fail at 5s. This may be normal: first request will take longer in development (as next.js builds), but in production, if there's variance here, requests could fail in production if over 5s` + : `${stackItem.speed} seconds`} {properties.validProperties.map(([propertyKey, value]) => { @@ -283,7 +282,9 @@ const FramesRequestCardContentIcon: React.FC<{ const FramesRequestCardContent: React.FC<{ stack: FramesStack; - fetchFrame: UseFrameReturnValue["fetchFrame"]; + fetchFrame: FrameState< + FarcasterSigner | XmtpSigner | LensSigner | AnonymousSigner | null + >["fetchFrame"]; }> = ({ fetchFrame, stack }) => { return stack.map((frameStackItem, i) => { return ( @@ -368,10 +369,10 @@ type FrameDebuggerSharedProps = { type FrameDebuggerProps = FrameDebuggerSharedProps & ( | { - useFrameHook: () => UseFrameReturnValue; + useFrameHook: () => FrameState; } | { - frameState: UseFrameReturnValue; + frameState: FrameState; } ); diff --git a/packages/debugger/app/debugger-page.tsx b/packages/debugger/app/debugger-page.tsx index 7d7fca085..afba6a646 100644 --- a/packages/debugger/app/debugger-page.tsx +++ b/packages/debugger/app/debugger-page.tsx @@ -4,12 +4,17 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { + type UseFrameOptions, fallbackFrameContext, type OnTransactionFunc, type OnSignatureFunc, + type FrameActionBodyPayload, type OnConnectWalletFunc, + type FrameContext, + type FarcasterFrameContext, } from "@frames.js/render"; import { attribution } from "@frames.js/render/farcaster"; +import { useFrame } from "@frames.js/render/use-frame"; import { ConnectButton, useConnectModal } from "@rainbow-me/rainbowkit"; import { sendTransaction, signTypedData, switchChain } from "@wagmi/core"; import { useRouter } from "next/navigation"; @@ -35,10 +40,7 @@ import { ActionDebugger, ActionDebuggerRef, } from "./components/action-debugger"; -import type { - ParseFramesWithReportsResult, - ParseResult, -} from "frames.js/frame-parsers"; +import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; import { Loader2 } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@/components/ui/toast"; @@ -65,10 +67,6 @@ import { useXmtpFrameContext, useXmtpIdentity, } from "@frames.js/render/identity/xmtp"; -import { - useFrame, - type UseFrameOptions, -} from "@frames.js/render/unstable-use-frame"; const FALLBACK_URL = process.env.NEXT_PUBLIC_DEBUGGER_DEFAULT_URL || "http://localhost:3000"; @@ -179,6 +177,7 @@ export default function DebuggerPage({ const searchParams = new URLSearchParams({ url: newUrl || url, + specification: protocolConfiguration?.specification, actions: "true", }); const proxiedUrl = `/frames?${searchParams.toString()}`; @@ -302,6 +301,8 @@ export default function DebuggerPage({ }, }); + const anonymousFrameContext = {}; + const onConnectWallet: OnConnectWalletFunc = useCallback(async () => { if (!openConnectModal) { throw new Error(`openConnectModal not implemented`); @@ -426,66 +427,20 @@ export default function DebuggerPage({ [account.address, currentChainId, config, openConnectModal, toast] ); - const useFrameConfig: UseFrameOptions = useMemo( + const useFrameConfig: Omit< + UseFrameOptions< + Record, + FrameActionBodyPayload, + FrameContext + >, + "signerState" | "specification" | "frameContext" + > = useMemo( () => ({ homeframeUrl: url, - frame: initialFrame, + frame: + initialFrame?.[protocolConfiguration?.specification ?? "farcaster"], frameActionProxy: "/frames", frameGetProxy: "/frames", - resolveCastOrComposerActionSigner() { - console.log("resolve cast or composer action signer"); - - return { - signerState: farcasterSignerState, - frameContext: { - ...farcasterFrameContext.frameContext, - address: - account.address || farcasterFrameContext.frameContext.address, - }, - }; - }, - resolveSpecification() { - console.log("resolve spec"); - if ( - !protocolConfiguration?.protocol || - protocolConfiguration.protocol === "farcaster" - ) { - return { - frameContext: { - ...fallbackFrameContext, - address: account.address || fallbackFrameContext.address, - }, - signerState: farcasterSignerState, - specification: "farcaster", - }; - } - - switch (protocolConfiguration.protocol) { - case "anonymous": { - return { - specification: "openframes", - frameContext: {}, - signerState: anonymousSignerState, - }; - } - case "lens": { - return { - specification: "openframes", - frameContext: lensFrameContext.frameContext, - signerState: lensSignerState, - }; - } - case "xmtp": { - return { - specification: "openframes", - frameContext: xmtpFrameContext.frameContext, - signerState: xmtpSignerState, - }; - } - default: - throw new Error("Unknown protocol"); - } - }, connectedAddress: account.address, extraButtonRequestPayload: { mockData: mockHubContext }, onTransaction, @@ -609,67 +564,91 @@ export default function DebuggerPage({ }), [ account.address, - anonymousSignerState, - farcasterFrameContext.frameContext, - farcasterSignerState, initialFrame, - lensFrameContext.frameContext, - lensSignerState, mockHubContext, onConnectWallet, onSignature, onTransaction, openConnectModal, - protocolConfiguration?.protocol, toast, url, - xmtpFrameContext.frameContext, - xmtpSignerState, + protocolConfiguration?.specification, ] ); - const farcasterFrameConfig: UseFrameOptions = useMemo(() => { + const farcasterFrameConfig: UseFrameOptions< + FarcasterSigner | null, + FrameActionBodyPayload, + FarcasterFrameContext + > = useMemo(() => { const attributionData = process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID ? attribution(parseInt(process.env.NEXT_PUBLIC_FARCASTER_ATTRIBUTION_FID)) : undefined; return { ...useFrameConfig, - resolveCastOrComposerActionSigner() { - console.log("resolve cast or composer signer"); - return { - signerState: farcasterSignerState, - frameContext: { - ...farcasterFrameContext.frameContext, - address: - account.address || farcasterFrameContext.frameContext.address, - }, - }; - }, - resolveSpecification() { - console.log("resolve farcaster spec"); - return { - frameContext: { - ...farcasterFrameContext.frameContext, - address: - account.address || farcasterFrameContext.frameContext.address, - }, - signerState: farcasterSignerState, - specification: "farcaster", - }; + signerState: farcasterSignerState, + specification: "farcaster", + frameContext: { + ...fallbackFrameContext, + ...farcasterFrameContext.frameContext, }, transactionDataSuffix: attributionData, }; }, [ - account.address, farcasterFrameContext.frameContext, farcasterSignerState, useFrameConfig, ]); - const useFrameHook = useCallback(() => { - // eslint-disable-next-line react-hooks/rules-of-hooks -- hook is called in component - return useFrame(useFrameConfig); - }, [useFrameConfig]); + const useFrameHook = useMemo(() => { + return () => { + const selectedProtocol = protocolConfiguration?.protocol ?? "farcaster"; + + switch (selectedProtocol) { + case "anonymous": { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger + return useFrame({ + ...useFrameConfig, + signerState: anonymousSignerState, + specification: "openframes", + frameContext: anonymousFrameContext, + }); + } + case "lens": { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger + return useFrame({ + ...useFrameConfig, + signerState: lensSignerState, + specification: "openframes", + frameContext: lensFrameContext.frameContext, + }); + } + case "xmtp": { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger + return useFrame({ + ...useFrameConfig, + signerState: xmtpSignerState, + specification: "openframes", + frameContext: xmtpFrameContext.frameContext, + }); + } + default: { + // eslint-disable-next-line react-hooks/rules-of-hooks -- this is used as a hook in FrameDebugger + return useFrame(farcasterFrameConfig); + } + } + }; + }, [ + anonymousSignerState, + anonymousFrameContext, + farcasterFrameConfig, + lensFrameContext.frameContext, + lensSignerState, + protocolConfiguration?.protocol, + useFrameConfig, + xmtpFrameContext.frameContext, + xmtpSignerState, + ]); return ( diff --git a/packages/debugger/app/frames/route.ts b/packages/debugger/app/frames/route.ts index 9ec36cc26..c486a7551 100644 --- a/packages/debugger/app/frames/route.ts +++ b/packages/debugger/app/frames/route.ts @@ -25,16 +25,23 @@ const composerActionFormParser = z.object({ title: z.string().min(1), }); -const jsonResponseParser = z.preprocess((data) => { - if (typeof data === "object" && data !== null && !("type" in data)) { - return { - type: "message", - ...data, - }; - } +const jsonResponseParser = z.preprocess( + (data) => { + if (typeof data === "object" && data !== null && !("type" in data)) { + return { + type: "message", + ...data, + }; + } - return data; -}, z.discriminatedUnion("type", [castActionFrameParser, castActionMessageParser, composerActionFormParser])); + return data; + }, + z.discriminatedUnion("type", [ + castActionFrameParser, + castActionMessageParser, + composerActionFormParser, + ]) +); const errorResponseParser = z.object({ message: z.string().min(1), From 96db514cb742316a00ee3576a4c0e90918a320e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 28 Oct 2024 14:59:54 +0100 Subject: [PATCH 11/27] fix: incorrect specification --- .../src/identity/farcaster/use-farcaster-multi-identity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 520792e74..1a96b68b9 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx @@ -528,7 +528,7 @@ export function useFarcasterMultiIdentity({ return useMemo( () => ({ - specification: "openframes", + specification: "farcaster", signer: farcasterUser, hasSigner: farcasterUser?.status === "approved" || From 23b9b4a121282bd8e87550e481bee908d44825e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Thu, 31 Oct 2024 09:13:52 +0100 Subject: [PATCH 12/27] fix: call signerless press if user doesn't have a signer --- packages/render/src/unstable-use-frame.tsx | 30 ++++++++-------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 612ccd9d5..013ed3b83 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -269,11 +269,7 @@ export function useFrame_unstable({ } if (!currentState.signerState.hasSigner) { - const error = new Error("Missing signer"); - - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); - + await currentState.signerState.onSignerlessFramePress(); return; } @@ -314,9 +310,9 @@ export function useFrame_unstable({ buttonIndex: number; postInputText: string | undefined; }): Promise { - const state = frameStateRef.current; + const currentState = frameStateRef.current; - if (state.type === "not-initialized") { + if (currentState.type === "not-initialized") { const error = new Error("Cannot perform transaction without a frame"); console.error(`@frames.js/render: ${error.message}`); @@ -326,12 +322,8 @@ export function useFrame_unstable({ } // Send post request to get calldata - if (!state.signerState.hasSigner) { - const error = new Error("Missing signer"); - - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); - + if (!currentState.signerState.hasSigner) { + await currentState.signerState.onSignerlessFramePress(); return; } @@ -348,11 +340,11 @@ export function useFrame_unstable({ // transaction request always requires address const frameContext = { - ...state.frameContext, + ...currentState.frameContext, address: - "address" in state.frameContext && - typeof state.frameContext.address === "string" - ? state.frameContext.address + "address" in currentState.frameContext && + typeof currentState.frameContext.address === "string" + ? currentState.frameContext.address : connectedAddressRef.current, }; @@ -363,10 +355,10 @@ export function useFrame_unstable({ signerStateActionContext: { type: "tx-data", inputText: postInputText, - signer: state.signerState.signer, + signer: currentState.signerState.signer, frameContext, address: connectedAddressRef.current, - url: state.homeframeUrl, + url: currentState.homeframeUrl, target: frameButton.target, frameButton, buttonIndex, From aa58e18cbb6f27af65e324760720ed2a1b2d232c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Thu, 31 Oct 2024 09:48:14 +0100 Subject: [PATCH 13/27] feat: customizable frame state hook --- packages/render/package.json | 10 + packages/render/src/types.ts | 3 + packages/render/src/ui/frame.base.tsx | 14 +- packages/render/src/ui/utils.ts | 13 +- packages/render/src/unstable-types.ts | 376 ++++++++++++++++-- .../src/unstable-use-debugger-frame-state.ts | 150 +++++++ .../render/src/unstable-use-fetch-frame.ts | 8 +- .../render/src/unstable-use-frame-state.ts | 330 +++++++-------- packages/render/src/unstable-use-frame.tsx | 3 +- packages/render/src/use-frame-stack.ts | 13 +- 10 files changed, 688 insertions(+), 232 deletions(-) create mode 100644 packages/render/src/unstable-use-debugger-frame-state.ts diff --git a/packages/render/package.json b/packages/render/package.json index 22aa74646..c97d0099f 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -130,6 +130,16 @@ "default": "./dist/use-frame.cjs" } }, + "./unstable-use-debugger-frame-state": { + "import": { + "types": "./dist/unstable-use-debugger-frame-state.d.ts", + "default": "./dist/unstable-use-debugger-frame-state.js" + }, + "require": { + "types": "./dist/unstable-use-debugger-frame-state.d.cts", + "default": "./dist/unstable-use-debugger-frame-state.cjs" + } + }, "./unstable-use-frame-state": { "import": { "types": "./dist/unstable-use-frame-state.d.ts", diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts index b85c79107..6a524d703 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -433,6 +433,7 @@ export type FrameRequest< > = FrameGETRequest | FramePOSTRequest; export type FrameStackBase = { + id: number; timestamp: Date; /** speed in seconds */ speed: number; @@ -446,6 +447,7 @@ export type FrameStackBase = { }; export type FrameStackPostPending = { + id: number; method: "POST"; timestamp: Date; status: "pending"; @@ -458,6 +460,7 @@ export type FrameStackPostPending = { }; export type FrameStackGetPending = { + id: number; method: "GET"; timestamp: Date; status: "pending"; diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx index 26d2309d5..e718ddc08 100644 --- a/packages/render/src/ui/frame.base.tsx +++ b/packages/render/src/ui/frame.base.tsx @@ -141,7 +141,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -173,7 +173,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; @@ -182,7 +182,7 @@ export function BaseFrameUI>({ if (!currentFrameStackItem.request.sourceFrame) { frameUiState = { status: "loading", - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -190,7 +190,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } @@ -206,7 +206,7 @@ export function BaseFrameUI>({ ? currentFrameStackItem.frameResult.framesDebugInfo?.image : undefined, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else if ( @@ -220,7 +220,7 @@ export function BaseFrameUI>({ ? currentFrameStackItem.frameResult.framesDebugInfo?.image : undefined, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -235,7 +235,7 @@ export function BaseFrameUI>({ case "pending": { frameUiState = { status: "loading", - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; break; diff --git a/packages/render/src/ui/utils.ts b/packages/render/src/ui/utils.ts index c3a6db5c3..4abf00680 100644 --- a/packages/render/src/ui/utils.ts +++ b/packages/render/src/ui/utils.ts @@ -5,6 +5,11 @@ import type { FrameStackMessage, FrameStackRequestError, } from "../types"; +import type { + FramesStackItem as UnstableFramesStackItem, + FrameStackMessage as UnstableFrameStackMessage, + FrameStackRequestError as UnstableFrameStackRequestError, +} from "../unstable-types"; import type { PartialFrame } from "./types"; type FrameResultFailure = Exclude; @@ -16,7 +21,7 @@ type FrameStackItemWithPartialFrame = Omit & { }; export function isPartialFrameStackItem( - stackItem: FramesStackItem + stackItem: FramesStackItem | UnstableFramesStackItem ): stackItem is FrameStackItemWithPartialFrame { return ( stackItem.status === "done" && @@ -28,7 +33,11 @@ export function isPartialFrameStackItem( } export function getErrorMessageFromFramesStackItem( - item: FrameStackMessage | FrameStackRequestError + item: + | FrameStackMessage + | FrameStackRequestError + | UnstableFrameStackMessage + | UnstableFrameStackRequestError ): string { if (item.status === "message") { return item.message; diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index 8e3d62072..7b689f1b5 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -11,23 +11,21 @@ import type { ParseResultWithFrameworkDetails, } from "frames.js/frame-parsers"; import type { Dispatch } from "react"; +import type { ErrorMessageResponse } from "frames.js/types"; import type { ButtonPressFunction, FrameContext, + FrameGETRequest, + FramePOSTRequest, FrameRequest, - FrameStackBase, - FrameStackDoneRedirect, - FrameStackMessage, - FrameStackPending, - FrameStackRequestError, OnConnectWalletFunc, OnMintArgs, OnSignatureFunc, OnTransactionFunc, + SignedFrameAction, SignerStateActionContext, SignerStateInstance, } from "./types"; -import type { FrameState, FrameStateAPI } from "./unstable-use-frame-state"; export type ResolvedSigner = { /** @@ -48,7 +46,34 @@ export type ResolveSignerFunction = ( arg: ResolveSignerFunctionArg ) => ResolvedSigner; -export type UseFrameOptions = { +export type UseFrameOptions< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +> = { + /** + * The frame state to be used for the frame. Allows to store extra data on stack items. + */ + frameStateHook?: ( + options: Pick< + UseFrameStateOptions< + TExtraDataPending, + TExtraDataDone, + TExtraDataoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >, + "initialFrameUrl" | "initialParseResult" | "resolveSpecification" + > + ) => UseFrameStateReturn< + TExtraDataPending, + TExtraDataDone, + TExtraDataoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >; /** the route used to POST frame actions. The post_url will be added as a the `url` query parameter */ frameActionProxy: string; /** the route used to GET the initial frame via proxy */ @@ -119,21 +144,84 @@ export type UseFrameOptions = { > >; -export type FrameStackDone = FrameStackBase & { +export type FrameStackBase = { + id: number; + url: string; +}; + +export type FrameStackPostPending = Omit< + FrameStackBase, + "responseStatus" | "responseBody" +> & { + method: "POST"; + status: "pending"; + request: FramePOSTRequest; + extra: TExtra; +}; + +export type FrameStackGetPending = Omit< + FrameStackBase, + "responseStatus" | "responseBody" +> & { + method: "GET"; + status: "pending"; + request: FrameGETRequest; + extra: TExtra; +}; + +export type FrameStackPending = + | FrameStackGetPending + | FrameStackPostPending; + +export type FrameStackDone = FrameStackBase & { request: FrameRequest; - response: Response; frameResult: ParseResultWithFrameworkDetails; status: "done"; + extra: TExtra; }; -export type FramesStackItem = - | FrameStackPending - | FrameStackDone - | FrameStackDoneRedirect - | FrameStackRequestError - | FrameStackMessage; +export type FrameStackDoneRedirect = FrameStackBase & { + request: FramePOSTRequest; + location: string; + status: "doneRedirect"; + extra: TExtra; +}; -export type UseFrameReturnValue = { +export type FrameStackRequestError = FrameStackBase & { + request: FrameRequest; + status: "requestError"; + requestError: Error; + extra: TExtra; +}; + +export type FrameStackMessage = FrameStackBase & { + request: FramePOSTRequest; + status: "message"; + message: string; + type: "info" | "error"; + extra: TExtra; +}; + +export type FramesStackItem< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = + | FrameStackPending + | FrameStackDone + | FrameStackDoneRedirect + | FrameStackRequestError + | FrameStackMessage; + +export type UseFrameReturnValue< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +> = { /** * The signer state is set once it is resolved (on initial frame render) */ @@ -144,11 +232,25 @@ export type UseFrameReturnValue = { readonly specification: SupportedParsingSpecification | undefined; fetchFrame: FetchFrameFunction; clearFrameStack: () => void; - dispatchFrameStack: Dispatch; + dispatchFrameStack: Dispatch< + FrameReducerActions< + TExtraDataPending, + TExtraDataDone, + TExtraDataoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + > + >; /** The frame at the top of the stack (at index 0) */ readonly currentFrameStackItem: FramesStackItem | undefined; /** A stack of frames with additional context, with the most recent frame at index 0 */ - readonly framesStack: FramesStack; + readonly framesStack: FramesStack< + TExtraDataPending, + TExtraDataDone, + TExtraDataoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >; readonly inputText: string; setInputText: (s: string) => void; onButtonPress: ButtonPressFunction>; @@ -159,34 +261,51 @@ export type UseFrameReturnValue = { reset: () => void; }; -export type FramesStack = FramesStackItem[]; +export type FramesStack< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = FramesStackItem< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>[]; -export type FrameReducerActions = +export type FrameReducerActions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMessage = unknown, +> = | { action: "LOAD"; - item: FrameStackPending; + item: FrameStackPending; } | { action: "REQUEST_ERROR"; - pendingItem: FrameStackPending; - item: FrameStackRequestError; + pendingItem: FrameStackPending; + item: FrameStackRequestError; } | { action: "DONE_REDIRECT"; - pendingItem: FrameStackPending; - item: FrameStackDoneRedirect; + pendingItem: FrameStackPending; + item: FrameStackDoneRedirect; } | { action: "DONE_WITH_ERROR_MESSAGE"; - pendingItem: FrameStackPending; - item: Exclude; + pendingItem: FrameStackPending; + item: Exclude, { type: "info" }>; } | { action: "DONE"; - pendingItem: FrameStackPending; + pendingItem: FrameStackPending; parseResult: ParseFramesWithReportsResult; - response: Response; - endTime: Date; + extra: TExtraDone; } | { action: "CLEAR" } | { @@ -196,6 +315,7 @@ export type FrameReducerActions = action: "RESET_INITIAL_FRAME"; parseResult: ParseFramesWithReportsResult; homeframeUrl: string; + extra: TExtraDone; }; export type UseFetchFrameOptions = { @@ -313,3 +433,199 @@ export type FetchFrameFunction = ( */ shouldClear?: boolean ) => Promise; + +type MarkAsDoneArg = { + pendingItem: + | FrameStackGetPending + | FrameStackPostPending; + endTime: Date; + response: Response; + parseResult: ParseFramesWithReportsResult; + responseBody: unknown; +}; + +type MarkAsDonwWithRedirectArg = { + pendingItem: FrameStackPostPending; + endTime: Date; + location: string; + response: Response; + responseBody: unknown; +}; + +type MarkAsDoneWithErrorMessageArg = { + pendingItem: FrameStackPostPending; + endTime: Date; + response: Response; + responseData: ErrorMessageResponse; +}; + +type MarkAsFailedArg = { + pendingItem: + | FrameStackGetPending + | FrameStackPostPending; + endTime: Date; + requestError: Error; + response: Response | null; + responseBody: unknown; + responseStatus: number; +}; + +type MarkAsFailedWithRequestErrorArg = { + endTime: Date; + pendingItem: FrameStackPostPending; + error: Error; + response: Response; + responseBody: unknown; +}; + +type CreateGetPendingItemArg = { + request: FrameGETRequest; +}; + +type CreatePOSTPendingItemArg = { + action: SignedFrameAction; + request: FramePOSTRequest; + /** + * Optional, allows to override the start time + * + * @defaultValue new Date() + */ + startTime?: Date; +}; + +export type UseFrameStateOptions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { + initialParseResult?: ParseFramesWithReportsResult | null; + initialFrameUrl?: string | null; + initialPendingExtra?: TExtraPending; + resolveSpecification: ResolveSignerFunction; + resolveGETPendingExtra?: (arg: CreateGetPendingItemArg) => TExtraPending; + resolvePOSTPendingExtra?: (arg: CreatePOSTPendingItemArg) => TExtraPending; + resolveDoneExtra?: (arg: MarkAsDoneArg) => TExtraDone; + resolveDoneRedirectExtra?: ( + arg: MarkAsDonwWithRedirectArg + ) => TExtraDoneRedirect; + resolveDoneWithErrorMessageExtra?: ( + arg: MarkAsDoneWithErrorMessageArg + ) => TExtraMesssage; + resolveFailedExtra?: ( + arg: MarkAsFailedArg + ) => TExtraRequestError; + resolveFailedWithRequestErrorExtra?: ( + arg: MarkAsFailedWithRequestErrorArg + ) => TExtraRequestError; +}; + +export type FrameStateAPI< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { + dispatch: React.Dispatch< + FrameReducerActions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > + >; + clear: () => void; + createGetPendingItem: ( + arg: CreateGetPendingItemArg + ) => FrameStackGetPending; + createPostPendingItem: < + TSignerStateActionContext extends SignerStateActionContext, + >(arg: { + action: SignedFrameAction; + request: FramePOSTRequest; + /** + * Optional, allows to override the start time + * + * @defaultValue new Date() + */ + startTime?: Date; + }) => FrameStackPostPending; + markAsDone: (arg: MarkAsDoneArg) => void; + markAsDoneWithRedirect: ( + arg: MarkAsDonwWithRedirectArg + ) => void; + markAsDoneWithErrorMessage: ( + arg: MarkAsDoneWithErrorMessageArg + ) => void; + markAsFailed: (arg: MarkAsFailedArg) => void; + markAsFailedWithRequestError: ( + arg: MarkAsFailedWithRequestErrorArg + ) => void; + /** + * If arg is omitted it will reset the frame stack to initial frame and resolves the specification again. + * Otherwise it will set the frame state to provided values and resolve the specification. + */ + reset: (arg?: { + homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; + }) => void; +}; + +export type UseFrameStateReturn< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = [ + FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, + FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, +]; + +export type FrameState< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = + | { + type: "initialized"; + stack: FramesStack< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; + signerState: SignerStateInstance; + frameContext: FrameContext; + specification: SupportedParsingSpecification; + homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; + } + | { + type: "not-initialized"; + stack: FramesStack< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; + }; diff --git a/packages/render/src/unstable-use-debugger-frame-state.ts b/packages/render/src/unstable-use-debugger-frame-state.ts new file mode 100644 index 000000000..349949f72 --- /dev/null +++ b/packages/render/src/unstable-use-debugger-frame-state.ts @@ -0,0 +1,150 @@ +import type { + FrameState, + FrameStateAPI, + UseFrameStateOptions, +} from "./unstable-types"; +import { useFrameState } from "./unstable-use-frame-state"; + +function computeDurationInSeconds(start: Date, end: Date): number { + return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); +} + +type ExtraPending = { + startTime: Date; + requestDetails: { + body?: object; + searchParams?: URLSearchParams; + }; +}; + +type SharedResponseExtra = { + response: Response; + responseStatus: number; + responseBody: unknown; + /** + * The speed of the response in seconds + */ + speed: number; +}; + +type ExtraDone = SharedResponseExtra; + +type ExtraDoneRedirect = SharedResponseExtra; + +type ExtraRequestError = Pick & { + response: Response | null; + responseStatus: number; + responseBody: unknown; +}; + +type ExtraMessage = SharedResponseExtra; + +type DebuggerFrameState = FrameState< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage +>; +type DebuggerFrameStateAPI = FrameStateAPI< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage +>; + +type DebuggerFrameStateOptions = Omit< + UseFrameStateOptions< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage + >, + "resolveDoneExtra" +>; + +export function useDebuggerFrameState( + options: DebuggerFrameStateOptions +): [DebuggerFrameState, DebuggerFrameStateAPI] { + return useFrameState< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage + >({ + ...options, + resolveGETPendingExtra() { + return { + startTime: new Date(), + requestDetails: {}, + }; + }, + resolvePOSTPendingExtra(arg) { + return { + startTime: new Date(), + requestDetails: { + body: arg.action.body, + searchParams: arg.action.searchParams, + }, + }; + }, + resolveDoneExtra(arg) { + return { + response: arg.response.clone(), + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + responseBody: arg.responseBody, + responseStatus: arg.response.status, + }; + }, + resolveDoneRedirectExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseStatus: arg.response.status, + responseBody: arg.responseBody, + }; + }, + resolveDoneWithErrorMessageExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseBody: arg.responseData, + responseStatus: arg.response.status, + }; + }, + resolveFailedExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response?.clone() ?? null, + responseBody: arg.responseBody, + responseStatus: arg.responseStatus, + }; + }, + resolveFailedWithRequestErrorExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseBody: arg.responseBody, + responseStatus: arg.response.status, + }; + }, + }); +} diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts index d99c7d651..cec23245a 100644 --- a/packages/render/src/unstable-use-fetch-frame.ts +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -16,13 +16,13 @@ import { } from "./helpers"; import type { FetchFrameFunction, + FrameStackPending, + FrameStackPostPending, UseFetchFrameOptions, } from "./unstable-types"; import type { FrameGETRequest, FramePOSTRequest, - FrameStackPending, - FrameStackPostPending, SignedFrameAction, SignerStateActionContext, SignerStateInstance, @@ -204,7 +204,8 @@ export function useFetchFrame({ pendingItem: frameStackPendingItem, endTime, parseResult, - response, + responseBody: await response.clone().text(), + response: response.clone(), }); return; @@ -447,6 +448,7 @@ export function useFetchFrame({ parseResult: responseData, pendingItem, response, + responseBody: await response.clone().text(), }); tryCall(() => options?.onSuccess?.()); diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts index 464a6e245..45029b0ef 100644 --- a/packages/render/src/unstable-use-frame-state.ts +++ b/packages/render/src/unstable-use-frame-state.ts @@ -1,53 +1,48 @@ import type { MutableRefObject } from "react"; import { useMemo, useReducer, useRef } from "react"; +import type { SupportedParsingSpecification } from "frames.js/frame-parsers"; +import type { FrameContext, SignerStateInstance } from "./types"; import type { - ParseFramesWithReportsResult, - SupportedParsingSpecification, -} from "frames.js/frame-parsers"; -import type { ErrorMessageResponse } from "frames.js/types"; -import type { - FrameContext, - FrameGETRequest, - FramePOSTRequest, + FrameReducerActions, FrameStackGetPending, FrameStackPostPending, - SignedFrameAction, - SignerStateActionContext, - SignerStateInstance, -} from "./types"; -import type { - FrameReducerActions, - FramesStack, + FrameState, + FrameStateAPI, ResolveSignerFunction, + UseFrameStateOptions, + UseFrameStateReturn, } from "./unstable-types"; import { useFreshRef } from "./hooks/use-fresh-ref"; -function computeDurationInSeconds(start: Date, end: Date): number { - return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); -} - -export type FrameState = - | { - type: "initialized"; - stack: FramesStack; - signerState: SignerStateInstance; - frameContext: FrameContext; - specification: SupportedParsingSpecification; - homeframeUrl: string; - parseResult: ParseFramesWithReportsResult; - } - | { - type: "not-initialized"; - stack: FramesStack; - }; - -function createFramesStackReducer( - resolveSignerRef: MutableRefObject -) { +function createFramesStackReducer< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>(resolveSignerRef: MutableRefObject) { return function framesStackReducer( - state: FrameState, - action: FrameReducerActions - ): FrameState { + state: FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, + action: FrameReducerActions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > + ): FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > { switch (action.action) { case "LOAD": return { @@ -56,7 +51,7 @@ function createFramesStackReducer( }; case "DONE_REDIRECT": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -76,7 +71,7 @@ function createFramesStackReducer( } case "DONE_WITH_ERROR_MESSAGE": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -95,7 +90,7 @@ function createFramesStackReducer( } case "DONE": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -135,14 +130,8 @@ function createFramesStackReducer( state.stack[index] = { ...action.pendingItem, status: "done", - speed: computeDurationInSeconds( - action.pendingItem.timestamp, - action.endTime - ), frameResult: action.parseResult[specification], - response: action.response, - responseStatus: action.response.status, - responseBody: action.parseResult, + extra: action.extra, }; return { @@ -158,7 +147,7 @@ function createFramesStackReducer( } case "REQUEST_ERROR": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -211,17 +200,10 @@ function createFramesStackReducer( url: action.homeframeUrl, }, url: action.homeframeUrl, - requestDetails: {}, - response: new Response(JSON.stringify(frameResult), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - responseStatus: 200, - timestamp: new Date(), - speed: 0, + id: Date.now(), frameResult, status: "done", - responseBody: frameResult, + extra: action.extra, }, ], }; @@ -237,85 +219,69 @@ function createFramesStackReducer( }; } -type UseFrameStateOptions = { - initialParseResult?: ParseFramesWithReportsResult | null; - initialFrameUrl?: string | null; - resolveSpecification: ResolveSignerFunction; -}; - -export type FrameStateAPI = { - dispatch: React.Dispatch; - clear: () => void; - createGetPendingItem: (arg: { - request: FrameGETRequest; - }) => FrameStackGetPending; - createPostPendingItem: < - TSignerStateActionContext extends SignerStateActionContext, - >(arg: { - action: SignedFrameAction; - request: FramePOSTRequest; - /** - * Optional, allows to override the start time - * - * @defaultValue new Date() - */ - startTime?: Date; - }) => FrameStackPostPending; - markAsDone: (arg: { - pendingItem: FrameStackGetPending | FrameStackPostPending; - endTime: Date; - response: Response; - parseResult: ParseFramesWithReportsResult; - }) => void; - markAsDoneWithRedirect: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - location: string; - response: Response; - responseBody: unknown; - }) => void; - markAsDoneWithErrorMessage: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - response: Response; - responseData: ErrorMessageResponse; - }) => void; - markAsFailed: (arg: { - pendingItem: FrameStackGetPending | FrameStackPostPending; - endTime: Date; - requestError: Error; - response: Response | null; - responseBody: unknown; - responseStatus: number; - }) => void; - markAsFailedWithRequestError: (arg: { - endTime: Date; - pendingItem: FrameStackPostPending; - error: Error; - response: Response; - responseBody: unknown; - }) => void; - /** - * If arg is omitted it will reset the frame stack to initial frame and resolves the specification again. - * Otherwise it will set the frame state to provided values and resolve the specification. - */ - reset: (arg?: { - homeframeUrl: string; - parseResult: ParseFramesWithReportsResult; - }) => void; -}; - -export function useFrameState({ +export function useFrameState< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>({ initialParseResult, initialFrameUrl, + initialPendingExtra, resolveSpecification, -}: UseFrameStateOptions): [FrameState, FrameStateAPI] { + resolveGETPendingExtra, + resolvePOSTPendingExtra, + resolveDoneExtra, + resolveDoneRedirectExtra, + resolveDoneWithErrorMessageExtra, + resolveFailedExtra, + resolveFailedWithRequestErrorExtra, +}: UseFrameStateOptions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>): UseFrameStateReturn< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +> { + const idCounterRef = useRef(0); const resolveSpecificationRef = useFreshRef(resolveSpecification); - const reducerRef = useRef(createFramesStackReducer(resolveSpecificationRef)); + const resolveGETPendingExtraRef = useFreshRef(resolveGETPendingExtra); + const resolvePOSTPendingExtraRef = useFreshRef(resolvePOSTPendingExtra); + const resolveDoneExtraRef = useFreshRef(resolveDoneExtra); + const resolveDoneRedirectExtraRef = useFreshRef(resolveDoneRedirectExtra); + const resolveDoneWithErrorMessageExtraRef = useFreshRef( + resolveDoneWithErrorMessageExtra + ); + const resolveFailedExtraRef = useFreshRef(resolveFailedExtra); + const resolveFailedWithRequestErrorExtraRef = useFreshRef( + resolveFailedWithRequestErrorExtra + ); + const reducerRef = useRef( + createFramesStackReducer< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >(resolveSpecificationRef) + ); const [state, dispatch] = useReducer( reducerRef.current, - [initialParseResult, initialFrameUrl] as const, - ([parseResult, frameUrl]): FrameState => { + [initialParseResult, initialFrameUrl, initialPendingExtra] as const, + ([parseResult, frameUrl, extra]): FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > => { if (parseResult && frameUrl) { const { frameContext = {}, signerState } = resolveSpecification({ parseResult, @@ -331,22 +297,15 @@ export function useFrameState({ parseResult, stack: [ { - response: new Response(JSON.stringify(frameResult), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - responseStatus: 200, - responseBody: frameResult, - timestamp: new Date(), - requestDetails: {}, + id: idCounterRef.current++, request: { method: "GET", url: frameUrl, }, - speed: 0, frameResult, status: "done", url: frameUrl, + extra: (extra ?? {}) as TExtraDone, }, ], }; @@ -358,15 +317,15 @@ export function useFrameState({ ? [ // prevent flash of empty content by adding pending item because initial frame is being loaded { + id: idCounterRef.current++, method: "GET", request: { method: "GET", url: frameUrl, }, url: frameUrl, - requestDetails: {}, - timestamp: new Date(), status: "pending", + extra: (extra ?? {}) as TExtraPending, }, ] : [], @@ -374,7 +333,13 @@ export function useFrameState({ } ); - const api: FrameStateAPI = useMemo(() => { + const api: FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > = useMemo(() => { return { dispatch, clear() { @@ -383,13 +348,14 @@ export function useFrameState({ }); }, createGetPendingItem(arg) { - const item: FrameStackGetPending = { + const item: FrameStackGetPending = { + id: idCounterRef.current++, method: "GET", request: arg.request, - requestDetails: {}, url: arg.request.url, - timestamp: new Date(), status: "pending", + extra: + resolveGETPendingExtraRef.current?.(arg) ?? ({} as TExtraPending), }; dispatch({ @@ -400,16 +366,14 @@ export function useFrameState({ return item; }, createPostPendingItem(arg) { - const item: FrameStackPostPending = { + const item: FrameStackPostPending = { + id: idCounterRef.current++, method: "POST", request: arg.request, - requestDetails: { - body: arg.action.body, - searchParams: arg.action.searchParams, - }, url: arg.action.searchParams.get("postUrl") ?? "missing postUrl", - timestamp: arg.startTime ?? new Date(), status: "pending", + extra: + resolvePOSTPendingExtraRef.current?.(arg) ?? ({} as TExtraPending), }; dispatch({ @@ -424,8 +388,7 @@ export function useFrameState({ action: "DONE", pendingItem: arg.pendingItem, parseResult: arg.parseResult, - response: arg.response.clone(), - endTime: arg.endTime, + extra: resolveDoneExtraRef.current?.(arg) ?? ({} as TExtraDone), }); }, markAsDoneWithErrorMessage(arg) { @@ -434,16 +397,12 @@ export function useFrameState({ pendingItem: arg.pendingItem, item: { ...arg.pendingItem, - responseStatus: arg.response.status, - response: arg.response.clone(), - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), status: "message", type: "error", message: arg.responseData.message, - responseBody: arg.responseData, + extra: + resolveDoneWithErrorMessageExtraRef.current?.(arg) ?? + ({} as TExtraMesssage), }, }); }, @@ -454,14 +413,10 @@ export function useFrameState({ item: { ...arg.pendingItem, location: arg.location, - response: arg.response.clone(), - responseBody: arg.responseBody, - responseStatus: arg.response.status, status: "doneRedirect", - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), + extra: + resolveDoneRedirectExtraRef.current?.(arg) ?? + ({} as TExtraDoneRedirect), }, }); }, @@ -471,18 +426,13 @@ export function useFrameState({ pendingItem: arg.pendingItem, item: { request: arg.pendingItem.request, - requestDetails: arg.pendingItem.requestDetails, - timestamp: arg.pendingItem.timestamp, + id: arg.pendingItem.id, url: arg.pendingItem.url, - response: arg.response?.clone() ?? null, - responseStatus: arg.responseStatus, requestError: arg.requestError, - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), status: "requestError", - responseBody: arg.responseBody, + extra: + resolveFailedExtraRef.current?.(arg) ?? + ({} as TExtraRequestError), }, }); }, @@ -494,13 +444,9 @@ export function useFrameState({ ...arg.pendingItem, status: "requestError", requestError: arg.error, - response: arg.response.clone(), - responseStatus: arg.response.status, - responseBody: arg.responseBody, - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), + extra: + resolveFailedWithRequestErrorExtraRef.current?.(arg) ?? + ({} as TExtraRequestError), }, }); }, @@ -512,11 +458,21 @@ export function useFrameState({ action: "RESET_INITIAL_FRAME", homeframeUrl: arg.homeframeUrl, parseResult: arg.parseResult, + extra: (initialPendingExtra ?? {}) as TExtraDone, }); } }, }; - }, [dispatch]); + }, [ + initialPendingExtra, + resolveDoneExtraRef, + resolveDoneRedirectExtraRef, + resolveDoneWithErrorMessageExtraRef, + resolveFailedExtraRef, + resolveFailedWithRequestErrorExtraRef, + resolveGETPendingExtraRef, + resolvePOSTPendingExtraRef, + ]); return [state, api]; } diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 013ed3b83..ec8e77bb2 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -138,6 +138,7 @@ export type { // eslint-disable-next-line camelcase -- this is only temporary export function useFrame_unstable({ + frameStateHook: useFrameStateHook = useFrameState, homeframeUrl, onMint = onMintFallback, onTransaction = onTransactionFallback, @@ -168,7 +169,7 @@ export function useFrame_unstable({ }: UseFrameOptions): UseFrameReturnValue { const [inputText, setInputText] = useState(""); const inputTextRef = useFreshRef(inputText); - const [frameState, frameStateAPI] = useFrameState({ + const [frameState, frameStateAPI] = useFrameStateHook({ resolveSpecification, initialFrameUrl: homeframeUrl, initialParseResult: frame, diff --git a/packages/render/src/use-frame-stack.ts b/packages/render/src/use-frame-stack.ts index 2c49ae757..8dda97991 100644 --- a/packages/render/src/use-frame-stack.ts +++ b/packages/render/src/use-frame-stack.ts @@ -1,4 +1,4 @@ -import { useMemo, useReducer } from "react"; +import { type MutableRefObject, useMemo, useReducer, useRef } from "react"; import type { Frame, SupportedParsingSpecification } from "frames.js"; import type { ParseResult } from "frames.js/frame-parsers"; import type { @@ -23,6 +23,7 @@ function computeDurationInSeconds(start: Date, end: Date): number { } function framesStackReducer( + idCounterRef: MutableRefObject, state: FramesStack, action: FrameReducerActions ): FramesStack { @@ -83,6 +84,7 @@ function framesStackReducer( return [ { + id: idCounterRef.current++, request: { method: "GET", url: action.homeframeUrl ?? "", @@ -204,8 +206,9 @@ export function useFrameStack({ React.Dispatch, FrameStackAPI, ] { + const idCounterRef = useRef(0); const [stack, dispatch] = useReducer( - framesStackReducer, + framesStackReducer.bind(null, idCounterRef), [initialFrame, initialFrameUrl, initialSpecification] as const, ([frame, frameUrl, specification]): FramesStack => { if (frame) { @@ -219,6 +222,7 @@ export function useFrameStack({ }; return [ { + id: idCounterRef.current++, response: new Response(JSON.stringify(frameResult), { status: 200, headers: { "Content-Type": "application/json" }, @@ -242,6 +246,7 @@ export function useFrameStack({ // this is then handled by fetchFrame having second argument set to true so the stack is cleared return [ { + id: idCounterRef.current++, method: "GET", request: { method: "GET", @@ -268,6 +273,7 @@ export function useFrameStack({ }, createGetPendingItem(arg) { const item: FrameStackGetPending = { + id: idCounterRef.current++, method: "GET", request: arg.request, requestDetails: {}, @@ -285,6 +291,7 @@ export function useFrameStack({ }, createPostPendingItem(arg) { const item: FrameStackPostPending = { + id: idCounterRef.current++, method: "POST", request: arg.request, requestDetails: { @@ -305,6 +312,7 @@ export function useFrameStack({ }, createCastOrComposerActionPendingItem(arg) { return { + id: idCounterRef.current++, method: "POST", requestDetails: { body: arg.action.body, @@ -405,6 +413,7 @@ export function useFrameStack({ action: "REQUEST_ERROR", pendingItem: arg.pendingItem, item: { + id: arg.pendingItem.id, request: arg.pendingItem.request, requestDetails: arg.pendingItem.requestDetails, timestamp: arg.pendingItem.timestamp, From 7b457a103d13f3db8d5bf27960662eb23821cce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 4 Nov 2024 14:13:15 +0100 Subject: [PATCH 14/27] fix: make hooks generic --- packages/render/src/unstable-types.ts | 28 +++++++++++++------ .../render/src/unstable-use-fetch-frame.ts | 24 ++++++++++++---- packages/render/src/unstable-use-frame.tsx | 22 +++++++++++++-- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index 7b689f1b5..cc0db711d 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -49,7 +49,7 @@ export type ResolveSignerFunction = ( export type UseFrameOptions< TExtraDataPending = unknown, TExtraDataDone = unknown, - TExtraDataoneRedirect = unknown, + TExtraDataDoneRedirect = unknown, TExtraDataRequestError = unknown, TExtraDataMesssage = unknown, > = { @@ -61,7 +61,7 @@ export type UseFrameOptions< UseFrameStateOptions< TExtraDataPending, TExtraDataDone, - TExtraDataoneRedirect, + TExtraDataDoneRedirect, TExtraDataRequestError, TExtraDataMesssage >, @@ -70,7 +70,7 @@ export type UseFrameOptions< ) => UseFrameStateReturn< TExtraDataPending, TExtraDataDone, - TExtraDataoneRedirect, + TExtraDataDoneRedirect, TExtraDataRequestError, TExtraDataMesssage >; @@ -218,7 +218,7 @@ export type FramesStackItem< export type UseFrameReturnValue< TExtraDataPending = unknown, TExtraDataDone = unknown, - TExtraDataoneRedirect = unknown, + TExtraDataDoneRedirect = unknown, TExtraDataRequestError = unknown, TExtraDataMesssage = unknown, > = { @@ -236,7 +236,7 @@ export type UseFrameReturnValue< FrameReducerActions< TExtraDataPending, TExtraDataDone, - TExtraDataoneRedirect, + TExtraDataDoneRedirect, TExtraDataRequestError, TExtraDataMesssage > @@ -247,7 +247,7 @@ export type UseFrameReturnValue< readonly framesStack: FramesStack< TExtraDataPending, TExtraDataDone, - TExtraDataoneRedirect, + TExtraDataDoneRedirect, TExtraDataRequestError, TExtraDataMesssage >; @@ -318,9 +318,21 @@ export type FrameReducerActions< extra: TExtraDone; }; -export type UseFetchFrameOptions = { +export type UseFetchFrameOptions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { frameState: FrameState; - frameStateAPI: FrameStateAPI; + frameStateAPI: FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; /** * URL or path to the frame proxy handling GET requests. */ diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts index cec23245a..020cb9853 100644 --- a/packages/render/src/unstable-use-fetch-frame.ts +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -43,7 +43,13 @@ function defaultErrorHandler(error: Error): void { console.error(error); } -export function useFetchFrame({ +export function useFetchFrame< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>({ frameStateAPI, frameState, frameActionProxy, @@ -67,7 +73,13 @@ export function useFetchFrame({ onTransactionProcessingError, onTransactionProcessingStart, onTransactionProcessingSuccess, -}: UseFetchFrameOptions): FetchFrameFunction { +}: UseFetchFrameOptions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>): FetchFrameFunction { async function handleFailedResponse({ response, endTime, @@ -76,7 +88,7 @@ export function useFetchFrame({ }: { endTime: Date; response: Response; - frameStackPendingItem: FrameStackPending; + frameStackPendingItem: FrameStackPending; onError?: (error: Error) => void; }): Promise { if (response.ok) { @@ -228,7 +240,7 @@ export function useFetchFrame({ request: FramePOSTRequest, options?: { preflightRequest?: { - pendingFrameStackItem: FrameStackPostPending; + pendingFrameStackItem: FrameStackPostPending; startTime: Date; }; shouldClear?: boolean; @@ -236,7 +248,7 @@ export function useFetchFrame({ onSuccess?: () => void; } ): Promise { - let pendingItem: FrameStackPostPending; + let pendingItem: FrameStackPostPending; if (frameState.type === "not-initialized") { throw new Error( @@ -301,7 +313,7 @@ export function useFetchFrame({ onSuccess: onSuccessInternal, }: { response: Response; - currentPendingItem: FrameStackPostPending; + currentPendingItem: FrameStackPostPending; onError?: (error: Error) => void; onSuccess?: () => void; }): Promise { diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index ec8e77bb2..9ae47b87e 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -137,7 +137,13 @@ export type { }; // eslint-disable-next-line camelcase -- this is only temporary -export function useFrame_unstable({ +export function useFrame_unstable< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataDoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +>({ frameStateHook: useFrameStateHook = useFrameState, homeframeUrl, onMint = onMintFallback, @@ -166,7 +172,19 @@ export function useFrame_unstable({ onTransactionProcessingSuccess, onTransactionStart, onTransactionSuccess, -}: UseFrameOptions): UseFrameReturnValue { +}: UseFrameOptions< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage +>): UseFrameReturnValue< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage +> { const [inputText, setInputText] = useState(""); const inputTextRef = useFreshRef(inputText); const [frameState, frameStateAPI] = useFrameStateHook({ From 3900284088addba02c040971acbb521d4f0047bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 4 Nov 2024 14:21:29 +0100 Subject: [PATCH 15/27] fix: allow any frame state in frame ui --- packages/render/src/ui/frame.base.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx index e718ddc08..91e1fd0ec 100644 --- a/packages/render/src/ui/frame.base.tsx +++ b/packages/render/src/ui/frame.base.tsx @@ -28,7 +28,9 @@ export type FrameUITheme> = Partial>; export type BaseFrameUIProps> = { - frameState: FrameState | UseFrameReturnValue; + frameState: + | FrameState + | UseFrameReturnValue; /** * Renders also frames that contain only image and at least one button * From c977e3aa168f619e95c40a1487f6e79fb7422ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 09:24:40 +0100 Subject: [PATCH 16/27] chore: changeset --- .changeset/cuddly-rivers-hide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cuddly-rivers-hide.md diff --git a/.changeset/cuddly-rivers-hide.md b/.changeset/cuddly-rivers-hide.md new file mode 100644 index 000000000..1b34e3cfd --- /dev/null +++ b/.changeset/cuddly-rivers-hide.md @@ -0,0 +1,5 @@ +--- +"@frames.js/render": patch +--- + +feat: customizable frame state for useFrame hook From c3f9dc8ff97ed7233699da0a332bdb3b4c670f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 4 Nov 2024 16:15:18 +0100 Subject: [PATCH 17/27] feat: mini app support --- packages/render/package.json | 13 +- packages/render/src/errors.ts | 6 + packages/render/src/farcaster/frames.tsx | 75 ++- packages/render/src/farcaster/signers.tsx | 5 +- packages/render/src/helpers.ts | 35 + .../farcaster/use-farcaster-identity.tsx | 31 +- .../use-farcaster-multi-identity.tsx | 38 +- packages/render/src/mini-app-messages.ts | 135 ++++ packages/render/src/next/POST.tsx | 86 ++- packages/render/src/unstable-types.ts | 34 +- packages/render/src/use-composer-action.ts | 613 ++++++++++++++++++ 11 files changed, 1017 insertions(+), 54 deletions(-) create mode 100644 packages/render/src/mini-app-messages.ts create mode 100644 packages/render/src/use-composer-action.ts diff --git a/packages/render/package.json b/packages/render/package.json index c97d0099f..41330a3e7 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -130,6 +130,16 @@ "default": "./dist/use-frame.cjs" } }, + "./use-composer-action": { + "import": { + "types": "./dist/use-composer-action.d.ts", + "default": "./dist/use-composer-action.js" + }, + "require": { + "types": "./dist/use-composer-action.d.cts", + "default": "./dist/use-composer-action.cjs" + } + }, "./unstable-use-debugger-frame-state": { "import": { "types": "./dist/unstable-use-debugger-frame-state.d.ts", @@ -285,6 +295,7 @@ "dependencies": { "@farcaster/core": "^0.14.7", "@noble/ed25519": "^2.0.0", - "frames.js": "^0.19.5" + "frames.js": "^0.19.5", + "zod": "^3.23.8" } } diff --git a/packages/render/src/errors.ts b/packages/render/src/errors.ts index ad7dd010b..f6f8f74e3 100644 --- a/packages/render/src/errors.ts +++ b/packages/render/src/errors.ts @@ -33,3 +33,9 @@ export class ComposerActionUnexpectedResponseError extends Error { super("Unexpected composer action response from the server"); } } + +export class ComposerActionUserRejectedRequestError extends Error { + constructor() { + super("User rejected the request"); + } +} diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx index 7ccb9a013..64f3ec037 100644 --- a/packages/render/src/farcaster/frames.tsx +++ b/packages/render/src/farcaster/frames.tsx @@ -7,7 +7,7 @@ import { getFarcasterTime, makeFrameAction, } from "@farcaster/core"; -import { hexToBytes } from "viem"; +import { bytesToHex, hexToBytes } from "viem"; import type { FrameActionBodyPayload, FrameContext, @@ -15,9 +15,45 @@ import type { SignerStateActionContext, SignFrameActionFunc, } from "../types"; +import type { + SignComposerActionFunc, + SignerStateComposerActionContext, +} from "../unstable-types"; +import { tryCallAsync } from "../helpers"; import type { FarcasterSigner } from "./signers"; import type { FarcasterFrameContext } from "./types"; +/** + * Creates a singer request payload to fetch composer action url. + */ +export const signComposerAction: SignComposerActionFunc = + async function signComposerAction(signerPrivateKey, actionContext) { + const messageOrError = await tryCallAsync(() => + createComposerActionMessageWithSignerKey(signerPrivateKey, actionContext) + ); + + if (messageOrError instanceof Error) { + throw messageOrError; + } + + const { message, trustedBytes } = messageOrError; + + return { + untrustedData: { + buttonIndex: 1, + fid: actionContext.fid, + messageHash: bytesToHex(message.hash), + network: 1, + state: Buffer.from(message.data.frameActionBody.state).toString(), + timestamp: new Date().getTime(), + url: actionContext.url, + }, + trustedData: { + messageBytes: trustedBytes, + }, + }; + }; + /** Creates a frame action for use with `useFrame` and a proxy */ export const signFrameAction: SignFrameActionFunc< FarcasterSigner, @@ -104,6 +140,43 @@ export const signFrameAction: SignFrameActionFunc< }; }; +export async function createComposerActionMessageWithSignerKey( + signerKey: string, + { fid, state, url }: SignerStateComposerActionContext +): 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(url), + buttonIndex: 1, + state: Buffer.from(encodeURIComponent(JSON.stringify({ cast: state }))), + }), + 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 08cd75195..58fe6b2a6 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,4 +1,5 @@ import type { FrameActionBodyPayload, SignerStateInstance } from "../types"; +import type { SignComposerActionFunc } from "../unstable-types"; import type { FarcasterFrameContext } from "./types"; export type FarcasterSignerState = @@ -6,7 +7,9 @@ export type FarcasterSignerState = TSignerType, FrameActionBodyPayload, FarcasterFrameContext - >; + > & { + signComposerAction: SignComposerActionFunc; + }; export type FarcasterSignerPendingApproval = { status: "pending_approval"; diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index 3346cc3ba..c18382b23 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -2,6 +2,7 @@ import type { ParseFramesWithReportsResult, ParseResult, } from "frames.js/frame-parsers"; +import type { ComposerActionFormResponse } from "frames.js/types"; import type { PartialFrame } from "./ui/types"; export async function tryCallAsync( @@ -71,3 +72,37 @@ export function isPartialFrame( value.frame.buttons.length > 0 ); } + +export function isComposerFormActionResponse( + response: unknown +): response is ComposerActionFormResponse { + return ( + typeof response === "object" && + response !== null && + "type" in response && + response.type === "form" + ); +} + +/** + * Merges all search params in order from left to right into the URL. + * + * @param url - The URL to merge the search params into. Either fully qualified or path only. + */ +export function mergeSearchParamsToUrl( + url: string, + ...searchParams: URLSearchParams[] +): string { + const temporaryDomain = "temporary-for-parsing-purposes.tld"; + const parsedProxyUrl = new URL(url, `http://${temporaryDomain}`); + + searchParams.forEach((params) => { + params.forEach((value, key) => { + parsedProxyUrl.searchParams.set(key, value); + }); + }); + + return parsedProxyUrl.hostname === temporaryDomain + ? `${parsedProxyUrl.pathname}${parsedProxyUrl.search}` + : parsedProxyUrl.toString(); +} diff --git a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx index a3bcb0226..db3d73b82 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -187,16 +188,12 @@ export function useFarcasterIdentity({ return value; }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -300,7 +297,7 @@ export function useFarcasterIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -329,14 +326,14 @@ export function useFarcasterIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -354,7 +351,7 @@ export function useFarcasterIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const farcasterUser = state.status === "init" ? null : state; @@ -418,6 +415,7 @@ export function useFarcasterIdentity({ visibilityDetector, setState, enableIdentityPolling, + onLogInRef, ]); return useMemo( @@ -428,6 +426,7 @@ export function useFarcasterIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, 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 1a96b68b9..b5142bbf2 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -259,20 +260,14 @@ export function useFarcasterMultiIdentity({ identities: [], }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const onIdentityRemoveRef = useRef(onIdentityRemove); - onIdentityRemoveRef.current = onIdentityRemove; - const onIdentitySelectRef = useRef(onIdentitySelect); - onIdentitySelectRef.current = onIdentitySelect; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const onIdentityRemoveRef = useFreshRef(onIdentityRemove); + const onIdentitySelectRef = useFreshRef(onIdentitySelect); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -388,7 +383,7 @@ export function useFarcasterMultiIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -417,14 +412,14 @@ export function useFarcasterMultiIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -442,7 +437,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const removeIdentity = useCallback(async () => { await setState((currentState) => { @@ -452,7 +447,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "REMOVE" }); }); - }, [setState]); + }, [onIdentityRemoveRef, setState]); const farcasterUser = state.activeIdentity; @@ -534,6 +529,7 @@ export function useFarcasterMultiIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, diff --git a/packages/render/src/mini-app-messages.ts b/packages/render/src/mini-app-messages.ts new file mode 100644 index 000000000..f1b1937cd --- /dev/null +++ b/packages/render/src/mini-app-messages.ts @@ -0,0 +1,135 @@ +import type { Abi, TypedDataDomain } from "viem"; +import { z } from "zod"; + +export type TransactionResponse = + | TransactionResponseSuccess + | TransactionResponseFailure; + +export type TransactionResponseSuccess = { + jsonrpc: "2.0"; + id: string | number | null; + result: TransactionSuccessBody; +}; + +export type TransactionSuccessBody = + | EthSendTransactionSuccessBody + | EthSignTypedDataV4SuccessBody; + +export type EthSendTransactionSuccessBody = { + address: `0x${string}`; + transactionHash: `0x${string}`; +}; + +export type EthSignTypedDataV4SuccessBody = { + address: `0x${string}`; + signature: `0x${string}`; +}; + +export type TransactionResponseFailure = { + jsonrpc: "2.0"; + id: string | number | null; + error: { + code: number; + message: string; + }; +}; + +export type CreateCastResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result: { + success: true; + }; +}; + +export type MiniAppResponse = TransactionResponse | CreateCastResponse; + +const createCastRequestSchemaLegacy = z.object({ + type: z.literal("createCast"), + data: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastLegacyMessage = z.infer< + typeof createCastRequestSchemaLegacy +>; + +const createCastRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number(), z.null()]), + method: z.literal("fc_createCast"), + params: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastMessage = z.infer; + +const ethSendTransactionActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_sendTransaction"), + attribution: z.boolean().optional(), + params: z.object({ + abi: z.custom(), + to: z.custom<`0x${string}`>( + (val): val is `0x${string}` => + typeof val === "string" && val.startsWith("0x") + ), + value: z.string().optional(), + data: z + .custom<`0x${string}`>((val): val is `0x${string}` => typeof val === "string" && val.startsWith("0x")) + .optional(), + }), +}); + +export type EthSendTransactionAction = z.infer< + typeof ethSendTransactionActionSchema +>; + +const ethSignTypedDataV4ActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_signTypedData_v4"), + params: z.object({ + domain: z.custom(), + types: z.unknown(), + primaryType: z.string(), + message: z.record(z.unknown()), + }), +}); + +export type EthSignTypedDataV4Action = z.infer< + typeof ethSignTypedDataV4ActionSchema +>; + +const walletActionRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string(), + method: z.literal("fc_requestWalletAction"), + params: z.object({ + action: z.union([ + ethSendTransactionActionSchema, + ethSignTypedDataV4ActionSchema, + ]), + }), +}); + +export type RequestWalletActionMessage = z.infer< + typeof walletActionRequestSchema +>; + +export const miniAppMessageSchema = z.union([ + createCastRequestSchemaLegacy, + walletActionRequestSchema, + createCastRequestSchema, +]); + +export type MiniAppMessage = z.infer; diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index c0d61e9f7..e9f216bb0 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -7,9 +7,48 @@ import { type ParseFramesWithReportsResult } from "frames.js/frame-parsers"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import type { JsonObject, JsonValue } from "frames.js/types"; import type { NextRequest } from "next/server"; +import { z } from "zod"; import { tryCallAsync } from "../helpers"; import { isSpecificationValid } from "./validators"; +const castActionMessageParser = z.object({ + type: z.literal("message"), + message: z.string().min(1), +}); + +const castActionFrameParser = z.object({ + type: z.literal("frame"), + frameUrl: z.string().min(1).url(), +}); + +const composerActionFormParser = z.object({ + type: z.literal("form"), + url: z.string().min(1).url(), + title: z.string().min(1), +}); + +const jsonResponseParser = z.preprocess( + (data) => { + if (typeof data === "object" && data !== null && !("type" in data)) { + return { + type: "message", + ...data, + }; + } + + return data; + }, + z.discriminatedUnion("type", [ + castActionFrameParser, + castActionMessageParser, + composerActionFormParser, + ]) +); + +const errorResponseParser = z.object({ + message: z.string().min(1), +}); + export type POSTResponseError = { message: string }; export type POSTResponseRedirect = { location: string }; @@ -23,15 +62,6 @@ export type POSTResponse = | POSTResponseRedirect | JsonObject; -function isJsonErrorObject(data: JsonValue): data is { message: string } { - return ( - typeof data === "object" && - data !== null && - "message" in data && - typeof data.message === "string" - ); -} - /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: Request | NextRequest): Promise { try { @@ -78,9 +108,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -136,9 +168,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -178,6 +212,32 @@ export async function POST(req: Request | NextRequest): Promise { return Response.json(transaction satisfies JsonObject); } + // Content type is JSON, could be an action + if ( + response.headers + .get("content-type") + ?.toLowerCase() + .includes("application/json") + ) { + const parseResult = await z + .promise(jsonResponseParser) + .safeParseAsync(response.clone().json()); + + if (!parseResult.success) { + throw new Error("Invalid frame response"); + } + + const headers = new Headers(response.headers); + // Proxied requests could have content-encoding set, which breaks the response + headers.delete("content-encoding"); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } + const html = await response.text(); if (multiSpecificationEnabled) { diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index cc0db711d..b6282cc5d 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -11,7 +11,10 @@ import type { ParseResultWithFrameworkDetails, } from "frames.js/frame-parsers"; import type { Dispatch } from "react"; -import type { ErrorMessageResponse } from "frames.js/types"; +import type { + ComposerActionState, + ErrorMessageResponse, +} from "frames.js/types"; import type { ButtonPressFunction, FrameContext, @@ -641,3 +644,32 @@ export type FrameState< TExtraMesssage >; }; + +export type SignerStateComposerActionContext = { + fid: number; + url: string; + state: ComposerActionState; +}; + +export type SignerComposerActionResult = { + untrustedData: { + fid: number; + url: string; + messageHash: `0x${string}`; + timestamp: number; + network: number; + buttonIndex: 1; + state: string; + }; + trustedData: { + messageBytes: string; + }; +}; + +/** + * Used to sign composer action + */ +export type SignComposerActionFunc = ( + signerPrivateKey: string, + actionContext: SignerStateComposerActionContext +) => Promise; diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts new file mode 100644 index 000000000..514de6519 --- /dev/null +++ b/packages/render/src/use-composer-action.ts @@ -0,0 +1,613 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import type { + ComposerActionFormResponse, + ComposerActionState, +} from "frames.js/types"; +import type { FarcasterSignerState } from "./farcaster"; +import { useFreshRef } from "./hooks/use-fresh-ref"; +import { + isComposerFormActionResponse, + mergeSearchParamsToUrl, + tryCall, + tryCallAsync, +} from "./helpers"; +import { + ComposerActionUnexpectedResponseError, + ComposerActionUserRejectedRequestError, +} from "./errors"; +import type { + EthSendTransactionAction, + EthSignTypedDataV4Action, + MiniAppMessage, + MiniAppResponse, +} from "./mini-app-messages"; +import { miniAppMessageSchema } from "./mini-app-messages"; +import type { FarcasterSigner } from "./identity/farcaster"; + +export type { MiniAppMessage, MiniAppResponse }; + +type FetchComposerActionFunctionArg = { + actionState: ComposerActionState; + proxyUrl: string; + signer: FarcasterSigner | null; + url: string; +}; + +type FetchComposerActionFunction = ( + arg: FetchComposerActionFunctionArg +) => Promise; + +type RegisterMessageListener = ( + formResponse: ComposerActionFormResponse, + messageListener: MiniAppMessageListener +) => () => void; + +type MiniAppMessageListener = (message: MiniAppMessage) => Promise; + +type OnTransactionFunction = (arg: { + action: EthSendTransactionAction; +}) => Promise<{ + hash: `0x${string}`; + address: `0x${string}`; +} | null>; +type OnSignatureFunction = (arg: { + action: EthSignTypedDataV4Action; +}) => Promise<{ + hash: `0x${string}`; + address: `0x${string}`; +} | null>; +type OnCreateCastFunction = (arg: { + cast: ComposerActionState; +}) => Promise; + +export type UseComposerActionOptions = { + /** + * Current action state, value should be memoized. It doesn't cause composer action / mini app to refetch. + */ + actionState: ComposerActionState; + /** + * URL to composer action / mini app server + * + * If value changes it will refetch the composer action / mini app + */ + url: string; + /** + * Signer used to sign the composer action. + * + * If value changes it will refetch the composer action / mini app + */ + signer: FarcasterSignerState; + /** + * URL to the action proxy server. If value changes composer action / mini app will be refetched. + * + * Proxy must handle POST requests. + */ + proxyUrl: string; + /** + * If enabled if will fetch the composer action / mini app on mount. + * + * @defaultValue true + */ + enabled?: boolean; + onError?: (error: Error) => void; + onCreateCast: OnCreateCastFunction; + onTransaction: OnTransactionFunction; + onSignature: OnSignatureFunction; + /** + * Called when a response to a message is sent to target (e.g. iframe). + */ + onPostResponseToTarget: (response: MiniAppResponse) => unknown; + /** + * Allows to override how the message listener is registered. Function must return a function that removes the listener. + * + * Changes in the value aren't reflected so it's recommended to use a memoized function. + * + * By default it uses window.addEventListener("message", ...) + */ + registerMessageListener?: RegisterMessageListener; +}; + +type UseComposerActionResult = { + refetch: () => Promise; +} & ( + | { + status: "idle"; + data: undefined; + error: undefined; + } + | { + status: "loading"; + data: undefined; + error: undefined; + } + | { + status: "error"; + data: undefined; + error: Error; + } + | { + status: "success"; + data: ComposerActionFormResponse; + error: undefined; + } +); + +export function useComposerAction({ + actionState, + enabled = true, + proxyUrl, + signer, + url, + onError, + onCreateCast, + onSignature, + onTransaction, + registerMessageListener = defaultRegisterMessageListener, + onPostResponseToTarget, +}: UseComposerActionOptions): UseComposerActionResult { + const onErrorRef = useFreshRef(onError); + const [state, dispatch] = useReducer(composerActionReducer, { + status: "idle", + enabled, + }); + const registerMessageListenerRef = useFreshRef(registerMessageListener); + const actionStateRef = useFreshRef(actionState); + const onCreateCastRef = useFreshRef(onCreateCast); + const onPostResponseToTargetRef = useFreshRef(onPostResponseToTarget); + const onTransactionRef = useFreshRef(onTransaction); + const onSignatureRef = useFreshRef(onSignature); + const lastFetchActionArgRef = useRef( + null + ); + const signerRef = useFreshRef(signer); + + const messageListener = useCallback( + async (message) => { + if ("type" in message || message.method === "fc_createCast") { + const cast = + "type" in message ? message.data.cast : message.params.cast; + + const resultOrError = await tryCallAsync(() => + onCreateCastRef.current({ + cast, + }) + ); + + if (resultOrError instanceof Error) { + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + result: { + success: true, + }, + }); + } else if (message.method === "fc_requestWalletAction") { + if (message.params.action.method === "eth_sendTransaction") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onTransactionRef.current({ + action, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }); + return; + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + transactionHash: resultOrError.hash, + }, + }); + } else if (message.params.action.method === "eth_signTypedData_v4") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onSignatureRef.current({ + action, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }); + + return; + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + signature: resultOrError.hash, + }, + }); + } else { + tryCall(() => + onErrorRef.current?.( + new Error( + `Unknown fc_requestWalletAction action method: ${message.params.action.method}` + ) + ) + ); + } + } else { + tryCall(() => onErrorRef.current?.(new Error("Unknown message"))); + } + }, + [ + onCreateCastRef, + onErrorRef, + onPostResponseToTargetRef, + onSignatureRef, + onTransactionRef, + ] + ); + + const fetchAction = useCallback( + async (arg) => { + const currentSigner = arg.signer; + + if ( + currentSigner?.status !== "approved" && + currentSigner?.status !== "impersonating" + ) { + await signerRef.current.onSignerlessFramePress(); + return; + } + + dispatch({ type: "loading-url" }); + + const signedDataOrError = await tryCallAsync(() => + signerRef.current.signComposerAction(currentSigner.privateKey, { + url: arg.url, + state: arg.actionState, + fid: currentSigner.fid, + }) + ); + + if (signedDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(signedDataOrError)); + dispatch({ type: "error", error: signedDataOrError }); + + return; + } + + const proxiedUrl = mergeSearchParamsToUrl( + arg.proxyUrl, + new URLSearchParams({ postUrl: arg.url }) + ); + + const actionResponseOrError = await tryCallAsync(() => + fetch(proxiedUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(signedDataOrError), + cache: "no-cache", + }) + ); + + if (actionResponseOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseOrError)); + dispatch({ type: "error", error: actionResponseOrError }); + + return; + } + + if (!actionResponseOrError.ok) { + const error = new Error( + `Unexpected response status ${actionResponseOrError.status}` + ); + + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + const actionResponseDataOrError = await tryCallAsync( + () => actionResponseOrError.clone().json() as Promise + ); + + if (actionResponseDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseDataOrError)); + dispatch({ type: "error", error: actionResponseDataOrError }); + + return; + } + + if (!isComposerFormActionResponse(actionResponseDataOrError)) { + const error = new ComposerActionUnexpectedResponseError(); + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + dispatch({ type: "done", response: actionResponseDataOrError }); + }, + [onErrorRef] + ); + + const stateRef = useFreshRef(state); + const refetch = useCallback(() => { + if (!stateRef.current.enabled || !lastFetchActionArgRef.current) { + return Promise.resolve(); + } + + return fetchAction(lastFetchActionArgRef.current); + }, [fetchAction, stateRef]); + + useEffect(() => { + dispatch({ type: "enabled-change", enabled }); + }, [enabled]); + + useEffect(() => { + if (!enabled) { + return; + } + + lastFetchActionArgRef.current = { + actionState: actionStateRef.current, + signer: signer.signer as unknown as FarcasterSigner | null, + url, + proxyUrl, + }; + + fetchAction(lastFetchActionArgRef.current).catch((e) => { + onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); + }); + }, [ + url, + proxyUrl, + signer.signer, + enabled, + fetchAction, + actionStateRef, + onErrorRef, + ]); + + // register message listener when state changes to success + useEffect(() => { + if (state.status === "success") { + return registerMessageListenerRef.current( + state.response, + messageListener + ); + } + }, [messageListener, registerMessageListenerRef, state]); + + return useMemo(() => { + switch (state.status) { + case "idle": + return { + status: "idle", + data: undefined, + error: undefined, + refetch, + }; + case "loading": + return { + status: "loading", + data: undefined, + error: undefined, + refetch, + }; + case "error": + return { + status: "error", + data: undefined, + error: state.error, + refetch, + }; + default: + return { + status: "success", + data: state.response, + error: undefined, + refetch, + }; + } + }, [state, refetch]); +} + +const defaultRegisterMessageListener: RegisterMessageListener = + function defaultRegisterMessageListener(formResponse, messageListener) { + if (typeof window === "undefined") { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: You are using default registerMessageListener in an environment without window object" + ); + + return () => { + // noop + }; + } + + const miniAppOrigin = new URL(formResponse.url).origin; + + const messageParserListener = (event: MessageEvent): void => { + // make sure that we only listen to messages from the mini app + if (event.origin !== miniAppOrigin) { + return; + } + + const result = miniAppMessageSchema.safeParse(event.data); + + if (!result.success) { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: Invalid message received", + event.data, + result.error + ); + return; + } + + const message = result.data; + + messageListener(message).catch((e) => { + // eslint-disable-next-line no-console -- provide feedback + console.error(`@frames.js/render:`, e); + }); + }; + + window.addEventListener("message", messageParserListener); + + return () => { + window.removeEventListener("message", messageParserListener); + }; + }; + +type SharedComposerActionReducerState = { + enabled: boolean; +}; + +type ComposerActionReducerState = SharedComposerActionReducerState & + ( + | { + status: "idle"; + } + | { + status: "loading"; + } + | { + status: "error"; + error: Error; + } + | { + status: "success"; + response: ComposerActionFormResponse; + } + ); + +type ComposerActionReducerAction = + | { + type: "loading-url"; + } + | { + type: "error"; + error: Error; + } + | { + type: "done"; + response: ComposerActionFormResponse; + } + | { + type: "enabled-change"; + enabled: boolean; + }; + +function composerActionReducer( + state: ComposerActionReducerState, + action: ComposerActionReducerAction +): ComposerActionReducerState { + if (action.type === "enabled-change") { + if (action.enabled) { + return { + ...state, + enabled: true, + }; + } + + return { + status: "idle", + enabled: false, + }; + } + + if (!state.enabled) { + return state; + } + + switch (action.type) { + case "done": + return { + ...state, + status: "success", + response: action.response, + }; + case "loading-url": + return { + ...state, + status: "loading", + }; + case "error": + return { + ...state, + status: "error", + error: action.error, + }; + default: + return state; + } +} From 0a0dddf5001d398495f1302c3ff75cc7825d7d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 08:30:23 +0100 Subject: [PATCH 18/27] feat: add resolveAddress function --- packages/render/src/mini-app-messages.ts | 8 ++- packages/render/src/use-composer-action.ts | 60 +++++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/render/src/mini-app-messages.ts b/packages/render/src/mini-app-messages.ts index f1b1937cd..d2a55ea0b 100644 --- a/packages/render/src/mini-app-messages.ts +++ b/packages/render/src/mini-app-messages.ts @@ -1,4 +1,4 @@ -import type { Abi, TypedDataDomain } from "viem"; +import type { Abi, TypedData, TypedDataDomain } from "viem"; import { z } from "zod"; export type TransactionResponse = @@ -100,7 +100,11 @@ const ethSignTypedDataV4ActionSchema = z.object({ method: z.literal("eth_signTypedData_v4"), params: z.object({ domain: z.custom(), - types: z.unknown(), + types: z.custom((value) => { + const result = z.record(z.unknown()).safeParse(value); + + return result.success; + }), primaryType: z.string(), message: z.record(z.unknown()), }), diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index 514de6519..963c5673c 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -44,22 +44,32 @@ type RegisterMessageListener = ( type MiniAppMessageListener = (message: MiniAppMessage) => Promise; -type OnTransactionFunction = (arg: { +export type OnTransactionFunctionResult = { + hash: `0x${string}`; + address: `0x${string}`; +}; + +export type OnTransactionFunction = (arg: { action: EthSendTransactionAction; -}) => Promise<{ + address: `0x${string}`; +}) => Promise; + +export type OnSignatureFunctionResult = { hash: `0x${string}`; address: `0x${string}`; -} | null>; -type OnSignatureFunction = (arg: { +}; + +export type OnSignatureFunction = (arg: { action: EthSignTypedDataV4Action; -}) => Promise<{ - hash: `0x${string}`; address: `0x${string}`; -} | null>; -type OnCreateCastFunction = (arg: { +}) => Promise; + +export type OnCreateCastFunction = (arg: { cast: ComposerActionState; }) => Promise; +export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; + export type UseComposerActionOptions = { /** * Current action state, value should be memoized. It doesn't cause composer action / mini app to refetch. @@ -89,6 +99,12 @@ export type UseComposerActionOptions = { * @defaultValue true */ enabled?: boolean; + /** + * Called before onTransaction/onSignature is invoked to obtain an address to use. + * + * If the function returns null onTransaction/onSignature will not be called. + */ + resolveAddress: ResolveAddressFunction; onError?: (error: Error) => void; onCreateCast: OnCreateCastFunction; onTransaction: OnTransactionFunction; @@ -142,6 +158,7 @@ export function useComposerAction({ onCreateCast, onSignature, onTransaction, + resolveAddress, registerMessageListener = defaultRegisterMessageListener, onPostResponseToTarget, }: UseComposerActionOptions): UseComposerActionResult { @@ -156,6 +173,7 @@ export function useComposerAction({ const onPostResponseToTargetRef = useFreshRef(onPostResponseToTarget); const onTransactionRef = useFreshRef(onTransaction); const onSignatureRef = useFreshRef(onSignature); + const resolveAddressRef = useFreshRef(resolveAddress); const lastFetchActionArgRef = useRef( null ); @@ -192,12 +210,36 @@ export function useComposerAction({ }, }); } else if (message.method === "fc_requestWalletAction") { + const addressOrError = await tryCallAsync(() => + resolveAddressRef.current() + ); + + if (addressOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(addressOrError)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: addressOrError.message, + }, + }); + + return; + } + + if (!addressOrError) { + return; + } + if (message.params.action.method === "eth_sendTransaction") { const action = message.params.action; const resultOrError = await tryCallAsync(() => onTransactionRef.current({ action, + address: addressOrError, }) ); @@ -246,6 +288,7 @@ export function useComposerAction({ const resultOrError = await tryCallAsync(() => onSignatureRef.current({ action, + address: addressOrError, }) ); @@ -308,6 +351,7 @@ export function useComposerAction({ onPostResponseToTargetRef, onSignatureRef, onTransactionRef, + resolveAddressRef, ] ); From 47193935b0df86bdb7ec922f07b815dee498f445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 09:11:21 +0100 Subject: [PATCH 19/27] feat: send form to post message handler --- packages/render/src/use-composer-action.ts | 171 +++++++++++++-------- 1 file changed, 103 insertions(+), 68 deletions(-) diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index 963c5673c..fe21c07c0 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -37,7 +37,7 @@ type FetchComposerActionFunction = ( arg: FetchComposerActionFunctionArg ) => Promise; -type RegisterMessageListener = ( +export type RegisterMessageListener = ( formResponse: ComposerActionFormResponse, messageListener: MiniAppMessageListener ) => () => void; @@ -70,6 +70,11 @@ export type OnCreateCastFunction = (arg: { export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; +export type OnPostMessageToTargetFunction = ( + response: MiniAppResponse, + form: ComposerActionFormResponse +) => unknown; + export type UseComposerActionOptions = { /** * Current action state, value should be memoized. It doesn't cause composer action / mini app to refetch. @@ -112,7 +117,7 @@ export type UseComposerActionOptions = { /** * Called when a response to a message is sent to target (e.g. iframe). */ - onPostResponseToTarget: (response: MiniAppResponse) => unknown; + onPostResponseToTarget: OnPostMessageToTargetFunction; /** * Allows to override how the message listener is registered. Function must return a function that removes the listener. * @@ -179,8 +184,11 @@ export function useComposerAction({ ); const signerRef = useFreshRef(signer); - const messageListener = useCallback( - async (message) => { + const messageListener = useCallback( + async ( + successState: Extract, + message: MiniAppMessage + ) => { if ("type" in message || message.method === "fc_createCast") { const cast = "type" in message ? message.data.cast : message.params.cast; @@ -192,23 +200,29 @@ export function useComposerAction({ ); if (resultOrError instanceof Error) { - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: "method" in message ? message.id : null, - error: { - code: -32000, - message: resultOrError.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + error: { + code: -32000, + message: resultOrError.message, + }, }, - }); + successState.response + ); } - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: "method" in message ? message.id : null, - result: { - success: true, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + result: { + success: true, + }, }, - }); + successState.response + ); } else if (message.method === "fc_requestWalletAction") { const addressOrError = await tryCallAsync(() => resolveAddressRef.current() @@ -217,14 +231,17 @@ export function useComposerAction({ if (addressOrError instanceof Error) { tryCall(() => onErrorRef.current?.(addressOrError)); - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: addressOrError.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: addressOrError.message, + }, }, - }); + successState.response + ); return; } @@ -246,14 +263,17 @@ export function useComposerAction({ if (resultOrError instanceof Error) { tryCall(() => onErrorRef.current?.(resultOrError)); - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: resultOrError.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, }, - }); + successState.response + ); return; } @@ -263,25 +283,31 @@ export function useComposerAction({ tryCall(() => onErrorRef.current?.(error)); - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: error.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, }, - }); + successState.response + ); return; } - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - result: { - address: resultOrError.address, - transactionHash: resultOrError.hash, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + transactionHash: resultOrError.hash, + }, }, - }); + successState.response + ); } else if (message.params.action.method === "eth_signTypedData_v4") { const action = message.params.action; @@ -295,14 +321,17 @@ export function useComposerAction({ if (resultOrError instanceof Error) { tryCall(() => onErrorRef.current?.(resultOrError)); - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: resultOrError.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, }, - }); + successState.response + ); return; } @@ -312,26 +341,32 @@ export function useComposerAction({ tryCall(() => onErrorRef.current?.(error)); - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: error.message, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, }, - }); + successState.response + ); return; } - onPostResponseToTargetRef.current({ - jsonrpc: "2.0", - id: message.id, - result: { - address: resultOrError.address, - signature: resultOrError.hash, + onPostResponseToTargetRef.current( + { + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + signature: resultOrError.hash, + }, }, - }); + successState.response + ); } else { tryCall(() => onErrorRef.current?.( @@ -439,7 +474,7 @@ export function useComposerAction({ dispatch({ type: "done", response: actionResponseDataOrError }); }, - [onErrorRef] + [onErrorRef, signerRef] ); const stateRef = useFreshRef(state); @@ -485,7 +520,7 @@ export function useComposerAction({ if (state.status === "success") { return registerMessageListenerRef.current( state.response, - messageListener + messageListener.bind(null, state) ); } }, [messageListener, registerMessageListenerRef, state]); From 6ebc3428724d4eac5c7423cf80fca455765cca74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 09:12:08 +0100 Subject: [PATCH 20/27] feat: use new composer action hook in debugger --- .../app/components/action-debugger.tsx | 98 +---- .../debugger/app/components/cast-composer.tsx | 48 +-- .../components/composer-action-debugger.tsx | 51 +++ .../composer-form-action-dialog.tsx | 381 ++++++++---------- packages/debugger/app/debugger-page.tsx | 41 +- packages/debugger/app/frames/route.ts | 168 +------- .../app/hooks/useFarcasterIdentity.tsx | 44 ++ packages/debugger/app/lib/utils.ts | 14 + packages/debugger/app/utils/mock-hub-utils.ts | 15 +- 9 files changed, 320 insertions(+), 540 deletions(-) create mode 100644 packages/debugger/app/components/composer-action-debugger.tsx create mode 100644 packages/debugger/app/hooks/useFarcasterIdentity.tsx diff --git a/packages/debugger/app/components/action-debugger.tsx b/packages/debugger/app/components/action-debugger.tsx index c6835584b..851358855 100644 --- a/packages/debugger/app/components/action-debugger.tsx +++ b/packages/debugger/app/components/action-debugger.tsx @@ -9,7 +9,6 @@ import { cn } from "@/lib/utils"; import { type FarcasterFrameContext, type FrameActionBodyPayload, - OnComposeFormActionFuncReturnType, defaultTheme, } from "@frames.js/render"; import { ParsingReport } from "frames.js"; @@ -26,7 +25,6 @@ import React, { useEffect, useImperativeHandle, useMemo, - useRef, useState, } from "react"; import { Button } from "../../@/components/ui/button"; @@ -37,12 +35,9 @@ import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import { useToast } from "@/components/ui/use-toast"; import type { CastActionDefinitionResponse } from "../frames/route"; -import { ComposerFormActionDialog } from "./composer-form-action-dialog"; -import { AwaitableController } from "../lib/awaitable-controller"; -import type { ComposerActionFormResponse } from "frames.js/types"; -import { CastComposer, CastComposerRef } from "./cast-composer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { ComposerActionDebugger } from "./composer-action-debugger"; type FrameDebuggerFramePropertiesTableRowsProps = { actionMetadataItem: CastActionDefinitionResponse; @@ -227,47 +222,9 @@ export const ActionDebugger = React.forwardRef< } }, [copySuccess, setCopySuccess]); - const [composeFormActionDialogSignal, setComposerFormActionDialogSignal] = - useState | null>(null); const actionFrameState = useFrame({ ...farcasterFrameConfig, - async onComposerFormAction({ form }) { - try { - const dialogSignal = new AwaitableController< - OnComposeFormActionFuncReturnType, - ComposerActionFormResponse - >(form); - - setComposerFormActionDialogSignal(dialogSignal); - - const result = await dialogSignal; - - // if result is undefined then user closed the dialog window without submitting - // otherwise we have updated data - if (result?.composerActionState) { - castComposerRef.current?.updateState(result.composerActionState); - } - - return result; - } catch (e) { - console.error(e); - toast({ - title: "Error occurred", - description: - e instanceof Error - ? e.message - : "Unexpected error, check the console for more info", - variant: "destructive", - }); - } finally { - setComposerFormActionDialogSignal(null); - } - }, }); - const castComposerRef = useRef(null); const [castActionDefinition, setCastActionDefinition] = useState refreshUrl()} > - { - if (actionMetadataItem.status !== "success") { - console.error(actionMetadataItem); - - toast({ - title: "Invalid action metadata", - description: - "Please check the console for more information", - variant: "destructive", - }); - return; - } - - Promise.resolve( - actionFrameState.onComposerActionButtonPress({ - castAction: { - ...actionMetadataItem.action, - url: actionMetadataItem.url, - }, - composerActionState, - // clear stack, this removes first item that will appear in the debugger - clearStack: true, - }) - ).catch((e: unknown) => { - // eslint-disable-next-line no-console -- provide feedback to the user - console.error(e); - }); + { + setActiveTab("cast-action"); }} /> - - {!!composeFormActionDialogSignal && ( - { - composeFormActionDialogSignal.resolve(undefined); - }} - onSave={({ composerState }) => { - composeFormActionDialogSignal.resolve({ - composerActionState: composerState, - }); - }} - onTransaction={farcasterFrameConfig.onTransaction} - onSignature={farcasterFrameConfig.onSignature} - /> - )} diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 8452fa809..e353259f1 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -11,29 +11,21 @@ import { ExternalLinkIcon, } from "lucide-react"; import IconByName from "./octicons"; -import { useFrame } from "@frames.js/render/use-frame"; +import { useFrame_unstable } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; -import type { - FarcasterFrameContext, - FrameActionBodyPayload, - FrameStackDone, -} from "@frames.js/render"; +import { fallbackFrameContext } from "@frames.js/render"; import { FrameUI } from "./frame-ui"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; -import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { useDebuggerFrameState } from "@frames.js/render/unstable-use-debugger-frame-state"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; +import { useAccount } from "wagmi"; +import { FrameStackDone } from "@frames.js/render/unstable-types"; type CastComposerProps = { composerAction: Partial; onComposerActionClick: (state: ComposerActionState) => any; - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; }; export type CastComposerRef = { @@ -43,7 +35,7 @@ export type CastComposerRef = { export const CastComposer = React.forwardRef< CastComposerRef, CastComposerProps ->(({ composerAction, farcasterFrameConfig, onComposerActionClick }, ref) => { +>(({ composerAction, onComposerActionClick }, ref) => { const [state, setState] = useState({ text: "", embeds: [], @@ -79,7 +71,6 @@ export const CastComposer = React.forwardRef< {state.embeds.slice(0, 2).map((embed, index) => (
  • { const filteredEmbeds = state.embeds.filter( (_, i) => i !== index @@ -119,13 +110,6 @@ export const CastComposer = React.forwardRef< CastComposer.displayName = "CastComposer"; type CastEmbedPreviewProps = { - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; url: string; onRemove: () => void; }; @@ -147,15 +131,19 @@ function isAtLeastPartialFrame(stackItem: FrameStackDone): boolean { ); } -function CastEmbedPreview({ - farcasterFrameConfig, - onRemove, - url, -}: CastEmbedPreviewProps) { +function CastEmbedPreview({ onRemove, url }: CastEmbedPreviewProps) { + const account = useAccount(); const { toast } = useToast(); - const frame = useFrame({ - ...farcasterFrameConfig, + const farcasterIdentity = useFarcasterIdentity(); + const frame = useFrame_unstable({ + frameStateHook: useDebuggerFrameState, + connectedAddress: account.address, homeframeUrl: url, + frameActionProxy: "/frames", + frameGetProxy: "/frames", + resolveSigner() { + return farcasterIdentity.withContext(fallbackFrameContext); + }, }); const handleFrameError = useCallback( diff --git a/packages/debugger/app/components/composer-action-debugger.tsx b/packages/debugger/app/components/composer-action-debugger.tsx new file mode 100644 index 000000000..faa620999 --- /dev/null +++ b/packages/debugger/app/components/composer-action-debugger.tsx @@ -0,0 +1,51 @@ +import type { + ComposerActionResponse, + ComposerActionState, +} from "frames.js/types"; +import { CastComposer, CastComposerRef } from "./cast-composer"; +import { useRef, useState } from "react"; +import { ComposerFormActionDialog } from "./composer-form-action-dialog"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; + +type ComposerActionDebuggerProps = { + url: string; + actionMetadata: Partial; + onToggleToCastActionDebugger: () => void; +}; + +export function ComposerActionDebugger({ + actionMetadata, + url, + onToggleToCastActionDebugger, +}: ComposerActionDebuggerProps) { + const castComposerRef = useRef(null); + const signer = useFarcasterIdentity(); + const [actionState, setActionState] = useState( + null + ); + + return ( + <> + + {!!actionState && ( + { + setActionState(null); + }} + onSubmit={(newActionState) => { + castComposerRef.current?.updateState(newActionState); + setActionState(null); + }} + onToggleToCastActionDebugger={onToggleToCastActionDebugger} + /> + )} + + ); +} diff --git a/packages/debugger/app/components/composer-form-action-dialog.tsx b/packages/debugger/app/components/composer-form-action-dialog.tsx index abea404db..11f98d54a 100644 --- a/packages/debugger/app/components/composer-form-action-dialog.tsx +++ b/packages/debugger/app/components/composer-form-action-dialog.tsx @@ -5,221 +5,158 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { OnSignatureFunc, OnTransactionFunc } from "@frames.js/render"; -import type { - ComposerActionFormResponse, - ComposerActionState, -} from "frames.js/types"; -import { useCallback, useEffect, useRef } from "react"; -import { Abi, TypedDataDomain } from "viem"; -import { z } from "zod"; - -const createCastRequestSchemaLegacy = z.object({ - type: z.literal("createCast"), - data: z.object({ - cast: z.object({ - parent: z.string().optional(), - text: z.string(), - embeds: z.array(z.string().min(1).url()).min(1), - }), - }), -}); - -const ethSendTransactionActionSchema = z.object({ - chainId: z.string(), - method: z.literal("eth_sendTransaction"), - attribution: z.boolean().optional(), - params: z.object({ - abi: z.custom(), - to: z.custom<`0x${string}`>( - (val): val is `0x${string}` => - typeof val === "string" && val.startsWith("0x") - ), - value: z.string().optional(), - data: z - .custom<`0x${string}`>( - (val): val is `0x${string}` => - typeof val === "string" && val.startsWith("0x") - ) - .optional(), - }), -}); - -const ethSignTypedDataV4ActionSchema = z.object({ - chainId: z.string(), - method: z.literal("eth_signTypedData_v4"), - params: z.object({ - domain: z.custom(), - types: z.unknown(), - primaryType: z.string(), - message: z.record(z.unknown()), - }), -}); - -const walletActionRequestSchema = z.object({ - jsonrpc: z.literal("2.0"), - id: z.string(), - method: z.literal("fc_requestWalletAction"), - params: z.object({ - action: z.union([ - ethSendTransactionActionSchema, - ethSignTypedDataV4ActionSchema, - ]), - }), -}); - -const createCastRequestSchema = z.object({ - jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number(), z.null()]), - method: z.literal("fc_createCast"), - params: z.object({ - cast: z.object({ - parent: z.string().optional(), - text: z.string(), - embeds: z.array(z.string().min(1).url()).min(1), - }), - }), -}); - -const composerActionMessageSchema = z.union([ - createCastRequestSchemaLegacy, - walletActionRequestSchema, - createCastRequestSchema, -]); +import { useComposerAction } from "@frames.js/render/use-composer-action"; +import type { ComposerActionState } from "frames.js/types"; +import { useRef } from "react"; +import { + useAccount, + useChainId, + useSendTransaction, + useSignTypedData, + useSwitchChain, +} from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useToast } from "@/components/ui/use-toast"; +import { ToastAction } from "@/components/ui/toast"; +import { parseEther } from "viem"; +import { parseChainId } from "../lib/utils"; +import { FarcasterMultiSignerInstance } from "@frames.js/render/identity/farcaster"; +import { AlertTriangleIcon, Loader2Icon } from "lucide-react"; type ComposerFormActionDialogProps = { - composerActionForm: ComposerActionFormResponse; + actionState: ComposerActionState; + url: string; + signer: FarcasterMultiSignerInstance; onClose: () => void; - onSave: (arg: { composerState: ComposerActionState }) => void; - onTransaction?: OnTransactionFunc; - onSignature?: OnSignatureFunc; - // TODO: Consider moving this into return value of onTransaction - connectedAddress?: `0x${string}`; + onSubmit: (actionState: ComposerActionState) => void; + onToggleToCastActionDebugger: () => void; }; -export function ComposerFormActionDialog({ - composerActionForm, +export const ComposerFormActionDialog = ({ + actionState, + signer, + url, onClose, - onSave, - onTransaction, - onSignature, - connectedAddress, -}: ComposerFormActionDialogProps) { - const onSaveRef = useRef(onSave); - onSaveRef.current = onSave; - + onSubmit, + onToggleToCastActionDebugger, +}: ComposerFormActionDialogProps) => { const iframeRef = useRef(null); + const { toast } = useToast(); + const account = useAccount(); + const currentChainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const { sendTransactionAsync } = useSendTransaction(); + const { signTypedDataAsync } = useSignTypedData(); + const { openConnectModal } = useConnectModal(); + + const result = useComposerAction({ + url, + enabled: !signer.isLoadingSigner, + proxyUrl: "/frames", + actionState, + signer, + async resolveAddress() { + if (account.address) { + return account.address; + } - const postMessageToIframe = useCallback( - (message: any) => { - if (iframeRef.current && iframeRef.current.contentWindow) { - iframeRef.current.contentWindow.postMessage( - message, - new URL(composerActionForm.url).origin - ); + if (!openConnectModal) { + throw new Error("Connect modal is not available"); } + + openConnectModal(); + + return null; }, - [composerActionForm.url] - ); + onError(error) { + console.error(error); + + if ( + error.message.includes( + "Unexpected composer action response from the server" + ) + ) { + toast({ + title: "Error occurred", + description: + "It seems that you tried to call a cast action in the composer action debugger.", + variant: "destructive", + action: ( + { + onToggleToCastActionDebugger(); + }} + > + Switch + + ), + }); - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.origin !== new URL(composerActionForm.url).origin) { return; } - const result = composerActionMessageSchema.safeParse(event.data); + toast({ + title: "Error occurred", + description: ( +
    +

    {error.message}

    +

    Please check the console for more information

    +
    + ), + variant: "destructive", + }); + }, + async onCreateCast(arg) { + onSubmit(arg.cast); + }, + async onSignature({ action, address }) { + const { chainId, params } = action; + const requestedChainId = parseChainId(chainId); - // on error is not called here because there can be different messages that don't have anything to do with composer form actions - // instead we are just waiting for the correct message - if (!result.success) { - console.warn("Invalid message received", event.data, result.error); - return; + if (currentChainId !== requestedChainId) { + await switchChainAsync({ chainId: requestedChainId }); } - const message = result.data; + const hash = await signTypedDataAsync(params); - if ("type" in message) { - // Handle legacy messages - onSaveRef.current({ - composerState: message.data.cast, - }); - } else if (message.method === "fc_requestWalletAction") { - if (message.params.action.method === "eth_sendTransaction") { - onTransaction?.({ - transactionData: message.params.action, - }).then((txHash) => { - if (txHash) { - postMessageToIframe({ - jsonrpc: "2.0", - id: message.id, - result: { - address: connectedAddress, - transactionId: txHash, - }, - }); - } else { - postMessageToIframe({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: "User rejected the request", - }, - }); - } - }); - } else if (message.params.action.method === "eth_signTypedData_v4") { - onSignature?.({ - signatureData: { - chainId: message.params.action.chainId, - method: message.params.action.method, - params: { - domain: message.params.action.params.domain, - types: message.params.action.params.types as any, - primaryType: message.params.action.params.primaryType, - message: message.params.action.params.message, - }, - }, - }).then((signature) => { - if (signature) { - postMessageToIframe({ - jsonrpc: "2.0", - id: message.id, - result: { - address: connectedAddress, - transactionId: signature, - }, - }); - } else { - postMessageToIframe({ - jsonrpc: "2.0", - id: message.id, - error: { - code: -32000, - message: "User rejected the request", - }, - }); - } - }); - } - } else if (message.method === "fc_createCast") { - if (message.params.cast.embeds.length > 2) { - console.warn("Only first 2 embeds are shown in the cast"); - } + return { + address, + hash, + }; + }, + async onTransaction({ action, address }) { + const { chainId, params } = action; + const requestedChainId = parseChainId(chainId); - onSaveRef.current({ - composerState: message.params.cast, - }); + if (currentChainId !== requestedChainId) { + await switchChainAsync({ chainId: requestedChainId }); } - }; - window.addEventListener("message", handleMessage); + const hash = await sendTransactionAsync({ + to: params.to, + data: params.data, + value: parseEther(params.value ?? "0"), + }); + + return { + address, + hash, + }; + }, + onPostResponseToTarget(message, form) { + if (iframeRef.current && iframeRef.current.contentWindow) { + iframeRef.current.contentWindow.postMessage( + message, + new URL(form.url).origin + ); + } + }, + }); - return () => { - window.removeEventListener("message", handleMessage); - }; - }, []); + if (result.status === "idle") { + return null; + } return ( - - {composerActionForm.title} - -
    - -
    - - - {new URL(composerActionForm.url).hostname} - - + {result.status === "loading" && ( + <> +
    + +
    + + )} + {result.status === "error" && ( + <> +
    + +

    + Something went wrong +

    +

    Check the console

    +
    + + )} + {result.status === "success" && ( + <> + + {result.data.title} + +
    + +
    + + + {new URL(result.data.url).hostname} + + + + )}
    ); -} +}; + +ComposerFormActionDialog.displayName = "ComposerFormActionDialog"; diff --git a/packages/debugger/app/debugger-page.tsx b/packages/debugger/app/debugger-page.tsx index afba6a646..cd390136b 100644 --- a/packages/debugger/app/debugger-page.tsx +++ b/packages/debugger/app/debugger-page.tsx @@ -56,7 +56,6 @@ import type { import { useAnonymousIdentity } from "@frames.js/render/identity/anonymous"; import { useFarcasterFrameContext, - useFarcasterMultiIdentity, type FarcasterSigner, } from "@frames.js/render/identity/farcaster"; import { @@ -67,25 +66,14 @@ import { useXmtpFrameContext, useXmtpIdentity, } from "@frames.js/render/identity/xmtp"; +import { useFarcasterIdentity } from "./hooks/useFarcasterIdentity"; +import { InvalidChainIdError, parseChainId } from "./lib/utils"; const FALLBACK_URL = process.env.NEXT_PUBLIC_DEBUGGER_DEFAULT_URL || "http://localhost:3000"; -class InvalidChainIdError extends Error {} class CouldNotChangeChainError extends Error {} -function isValidChainId(id: string): boolean { - return id.startsWith("eip155:"); -} - -function parseChainId(id: string): number { - if (!isValidChainId(id)) { - throw new InvalidChainIdError(`Invalid chainId ${id}`); - } - - return parseInt(id.split("eip155:")[1]!); -} - const anonymousFrameContext = {}; export default function DebuggerPage({ @@ -257,26 +245,8 @@ export default function DebuggerPage({ refreshUrl(url); }, [url, protocolConfiguration, refreshUrl, toast, debuggerConsole]); - const farcasterSignerState = useFarcasterMultiIdentity({ - onMissingIdentity() { - toast({ - title: "Please select an identity", - description: - "In order to test the buttons you need to select an identity first", - variant: "destructive", - action: ( - { - selectProtocolButtonRef.current?.click(); - }} - type="button" - > - Select identity - - ), - }); - }, + const farcasterSignerState = useFarcasterIdentity({ + selectProtocolButtonRef, }); const xmtpSignerState = useXmtpIdentity(); const lensSignerState = useLensIdentity(); @@ -301,8 +271,6 @@ export default function DebuggerPage({ }, }); - const anonymousFrameContext = {}; - const onConnectWallet: OnConnectWalletFunc = useCallback(async () => { if (!openConnectModal) { throw new Error(`openConnectModal not implemented`); @@ -640,7 +608,6 @@ export default function DebuggerPage({ }; }, [ anonymousSignerState, - anonymousFrameContext, farcasterFrameConfig, lensFrameContext.frameContext, lensSignerState, diff --git a/packages/debugger/app/frames/route.ts b/packages/debugger/app/frames/route.ts index c486a7551..b6f987512 100644 --- a/packages/debugger/app/frames/route.ts +++ b/packages/debugger/app/frames/route.ts @@ -1,51 +1,11 @@ -import { type FrameActionPayload } from "frames.js"; +import { POST as handlePOSTRequest } from "@frames.js/render/next"; import { type NextRequest } from "next/server"; import { getAction } from "../actions/getAction"; import { persistMockResponsesForDebugHubRequests } from "../utils/mock-hub-utils"; import type { SupportedParsingSpecification } from "frames.js"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; -import { z } from "zod"; import type { ParseActionResult } from "../actions/types"; import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; -import type { JsonObject } from "frames.js/types"; - -const castActionMessageParser = z.object({ - type: z.literal("message"), - message: z.string().min(1), -}); - -const castActionFrameParser = z.object({ - type: z.literal("frame"), - frameUrl: z.string().min(1).url(), -}); - -const composerActionFormParser = z.object({ - type: z.literal("form"), - url: z.string().min(1).url(), - title: z.string().min(1), -}); - -const jsonResponseParser = z.preprocess( - (data) => { - if (typeof data === "object" && data !== null && !("type" in data)) { - return { - type: "message", - ...data, - }; - } - - return data; - }, - z.discriminatedUnion("type", [ - castActionFrameParser, - castActionMessageParser, - composerActionFormParser, - ]) -); - -const errorResponseParser = z.object({ - message: z.string().min(1), -}); export type CastActionDefinitionResponse = ParseActionResult & { type: "action"; @@ -126,129 +86,7 @@ export async function GET(request: NextRequest): Promise { /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: NextRequest): Promise { - const body = (await req.clone().json()) as FrameActionPayload; - const isPostRedirect = - req.nextUrl.searchParams.get("postType") === "post_redirect"; - const isTransactionRequest = - req.nextUrl.searchParams.get("postType") === "tx"; - const postUrl = req.nextUrl.searchParams.get("postUrl"); - const specification = - req.nextUrl.searchParams.get("specification") ?? "farcaster"; - - if (!isSpecificationValid(specification)) { - return Response.json({ message: "Invalid specification" }, { status: 400 }); - } - - // TODO: refactor useful logic back into render package - - if (specification === "farcaster") { - await persistMockResponsesForDebugHubRequests(req); - } + await persistMockResponsesForDebugHubRequests(req); - if (!postUrl) { - return Response.json({ message: "Invalid post URL" }, { status: 400 }); - } - - try { - const r = await fetch(postUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - redirect: isPostRedirect ? "manual" : undefined, - body: JSON.stringify(body), - }); - - if (r.status === 302) { - return Response.json( - { - location: r.headers.get("location"), - }, - { status: 302 } - ); - } - - // this is an error, just return response as is - if (r.status >= 500) { - return Response.json(await r.text(), { status: r.status }); - } - - if (r.status >= 400 && r.status < 500) { - const parseResult = await z - .promise(errorResponseParser) - .safeParseAsync(r.clone().json()); - - if (!parseResult.success) { - return Response.json( - { message: await r.clone().text() }, - { status: r.status } - ); - } - - const headers = new Headers(r.headers); - // Proxied requests could have content-encoding set, which breaks the response - headers.delete("content-encoding"); - return new Response(r.body, { - headers, - status: r.status, - statusText: r.statusText, - }); - } - - if (isPostRedirect && r.status !== 302) { - return Response.json( - { - message: `Invalid response status code for post redirect button, 302 expected, got ${r.status}`, - }, - { status: 400 } - ); - } - - if (isTransactionRequest) { - const transaction = (await r.json()) as JsonObject; - return Response.json(transaction); - } - - // Content type is JSON, could be an action - if (r.headers.get("content-type")?.includes("application/json")) { - const parseResult = await z - .promise(jsonResponseParser) - .safeParseAsync(r.clone().json()); - - if (!parseResult.success) { - throw new Error("Invalid frame response"); - } - - const headers = new Headers(r.headers); - // Proxied requests could have content-encoding set, which breaks the response - headers.delete("content-encoding"); - return new Response(r.body, { - headers, - status: r.status, - statusText: r.statusText, - }); - } - - const html = await r.text(); - - const parseResult = parseFramesWithReports({ - html, - fallbackPostUrl: body.untrustedData.url, - fromRequestMethod: "POST", - }); - - return Response.json({ - type: "frame", - ...parseResult, - } satisfies FrameDefinitionResponse); - } catch (err) { - // eslint-disable-next-line no-console -- provide feedback to the user - console.error(err); - return Response.json( - { - message: String(err), - }, - { status: 500 } - ); - } + return handlePOSTRequest(req); } diff --git a/packages/debugger/app/hooks/useFarcasterIdentity.tsx b/packages/debugger/app/hooks/useFarcasterIdentity.tsx new file mode 100644 index 000000000..b3a1f544b --- /dev/null +++ b/packages/debugger/app/hooks/useFarcasterIdentity.tsx @@ -0,0 +1,44 @@ +import { ToastAction } from "@/components/ui/toast"; +import { useToast } from "@/components/ui/use-toast"; +import { useFarcasterMultiIdentity } from "@frames.js/render/identity/farcaster"; +import { WebStorage } from "@frames.js/render/identity/storage"; + +const sharedStorage = new WebStorage(); + +type Options = Omit< + Parameters[0], + "onMissingIdentity" +> & { + selectProtocolButtonRef?: React.RefObject; +}; + +export function useFarcasterIdentity({ + selectProtocolButtonRef, + ...options +}: Options = {}) { + const { toast } = useToast(); + + return useFarcasterMultiIdentity({ + ...(options ?? {}), + storage: sharedStorage, + onMissingIdentity() { + toast({ + title: "Please select an identity", + description: + "In order to test the buttons you need to select an identity first", + variant: "destructive", + action: selectProtocolButtonRef?.current ? ( + { + selectProtocolButtonRef?.current?.click(); + }} + type="button" + > + Select identity + + ) : undefined, + }); + }, + }); +} diff --git a/packages/debugger/app/lib/utils.ts b/packages/debugger/app/lib/utils.ts index 0c58f89ad..7d5402eff 100644 --- a/packages/debugger/app/lib/utils.ts +++ b/packages/debugger/app/lib/utils.ts @@ -18,3 +18,17 @@ export function hasWarnings(reports: Record): boolean { report.some((r) => r.level === "warning") ); } + +export class InvalidChainIdError extends Error {} + +export function isValidChainId(id: string): boolean { + return id.startsWith("eip155:"); +} + +export function parseChainId(id: string): number { + if (!isValidChainId(id)) { + throw new InvalidChainIdError(`Invalid chainId ${id}`); + } + + return parseInt(id.split("eip155:")[1]!); +} diff --git a/packages/debugger/app/utils/mock-hub-utils.ts b/packages/debugger/app/utils/mock-hub-utils.ts index 62001f16c..f57c0fab0 100644 --- a/packages/debugger/app/utils/mock-hub-utils.ts +++ b/packages/debugger/app/utils/mock-hub-utils.ts @@ -71,14 +71,17 @@ export async function loadMockResponseForDebugHubRequest( } export async function persistMockResponsesForDebugHubRequests(req: Request) { - const { - mockData, - untrustedData: { fid: requesterFid, castId }, - } = (await req.clone().json()) as { - mockData: MockHubActionContext; - untrustedData: { fid: string; castId: { fid: string; hash: string } }; + const { mockData, untrustedData } = (await req.clone().json()) as { + mockData?: MockHubActionContext; + untrustedData?: { fid: string; castId?: { fid: string; hash: string } }; }; + if (!mockData || !untrustedData?.castId) { + return; + } + + const { fid: requesterFid, castId } = untrustedData; + const requesterFollowsCaster = `/v1/linkById?${sortedSearchParamsString( new URLSearchParams({ fid: requesterFid, From cc31edf9e892f8742e6f7a0a1ecb94fe9789a4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 09:20:36 +0100 Subject: [PATCH 21/27] chore: changeset --- .changeset/grumpy-berries-love.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/grumpy-berries-love.md diff --git a/.changeset/grumpy-berries-love.md b/.changeset/grumpy-berries-love.md new file mode 100644 index 000000000..0969e5b84 --- /dev/null +++ b/.changeset/grumpy-berries-love.md @@ -0,0 +1,6 @@ +--- +"@frames.js/debugger": patch +"@frames.js/render": patch +--- + +feat: useComposerAction hook From f6248584d18c7378e54b45cf643317efbaf9fbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 14:23:29 +0100 Subject: [PATCH 22/27] feat: export message listener and parser --- packages/render/src/use-composer-action.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index fe21c07c0..c7a60a825 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -559,7 +559,12 @@ export function useComposerAction({ }, [state, refetch]); } -const defaultRegisterMessageListener: RegisterMessageListener = +export { miniAppMessageSchema }; + +/** + * Default function used to register message listener. Works in browsers only. + */ +export const defaultRegisterMessageListener: RegisterMessageListener = function defaultRegisterMessageListener(formResponse, messageListener) { if (typeof window === "undefined") { // eslint-disable-next-line no-console -- provide feedback From c38ef096405c67d54932f7ea36d75dd7f2b6af2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 5 Nov 2024 16:03:29 +0100 Subject: [PATCH 23/27] feat: allow to customize fetch function --- packages/render/src/use-composer-action.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index c7a60a825..fbca46e2d 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -104,6 +104,10 @@ export type UseComposerActionOptions = { * @defaultValue true */ enabled?: boolean; + /** + * Custom fetch function to fetch the composer action / mini app. + */ + fetch?: (url: string, init: RequestInit) => Promise; /** * Called before onTransaction/onSignature is invoked to obtain an address to use. * @@ -159,6 +163,7 @@ export function useComposerAction({ proxyUrl, signer, url, + fetch: fetchFunction, onError, onCreateCast, onSignature, @@ -183,6 +188,7 @@ export function useComposerAction({ null ); const signerRef = useFreshRef(signer); + const fetchRef = useFreshRef(fetchFunction); const messageListener = useCallback( async ( @@ -425,7 +431,7 @@ export function useComposerAction({ ); const actionResponseOrError = await tryCallAsync(() => - fetch(proxiedUrl, { + (fetchRef.current || fetch)(proxiedUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -474,7 +480,7 @@ export function useComposerAction({ dispatch({ type: "done", response: actionResponseDataOrError }); }, - [onErrorRef, signerRef] + [fetchRef, onErrorRef, signerRef] ); const stateRef = useFreshRef(state); From baa3f2d4619956c42a0cc134c37eeb8ddb38629f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 19 Nov 2024 09:08:00 +0100 Subject: [PATCH 24/27] chore: rename function --- packages/render/src/use-composer-action.ts | 30 +++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index fbca46e2d..2443cdadb 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -70,7 +70,7 @@ export type OnCreateCastFunction = (arg: { export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; -export type OnPostMessageToTargetFunction = ( +export type OnMessageRespondFunction = ( response: MiniAppResponse, form: ComposerActionFormResponse ) => unknown; @@ -119,9 +119,9 @@ export type UseComposerActionOptions = { onTransaction: OnTransactionFunction; onSignature: OnSignatureFunction; /** - * Called when a response to a message is sent to target (e.g. iframe). + * Called with message response to be posted to child (e.g. iframe). */ - onPostResponseToTarget: OnPostMessageToTargetFunction; + onMessageRespond: OnMessageRespondFunction; /** * Allows to override how the message listener is registered. Function must return a function that removes the listener. * @@ -170,7 +170,7 @@ export function useComposerAction({ onTransaction, resolveAddress, registerMessageListener = defaultRegisterMessageListener, - onPostResponseToTarget, + onMessageRespond, }: UseComposerActionOptions): UseComposerActionResult { const onErrorRef = useFreshRef(onError); const [state, dispatch] = useReducer(composerActionReducer, { @@ -180,7 +180,7 @@ export function useComposerAction({ const registerMessageListenerRef = useFreshRef(registerMessageListener); const actionStateRef = useFreshRef(actionState); const onCreateCastRef = useFreshRef(onCreateCast); - const onPostResponseToTargetRef = useFreshRef(onPostResponseToTarget); + const onMessageRespondRef = useFreshRef(onMessageRespond); const onTransactionRef = useFreshRef(onTransaction); const onSignatureRef = useFreshRef(onSignature); const resolveAddressRef = useFreshRef(resolveAddress); @@ -206,7 +206,7 @@ export function useComposerAction({ ); if (resultOrError instanceof Error) { - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: "method" in message ? message.id : null, @@ -219,7 +219,7 @@ export function useComposerAction({ ); } - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: "method" in message ? message.id : null, @@ -237,7 +237,7 @@ export function useComposerAction({ if (addressOrError instanceof Error) { tryCall(() => onErrorRef.current?.(addressOrError)); - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -269,7 +269,7 @@ export function useComposerAction({ if (resultOrError instanceof Error) { tryCall(() => onErrorRef.current?.(resultOrError)); - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -289,7 +289,7 @@ export function useComposerAction({ tryCall(() => onErrorRef.current?.(error)); - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -303,7 +303,7 @@ export function useComposerAction({ return; } - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -327,7 +327,7 @@ export function useComposerAction({ if (resultOrError instanceof Error) { tryCall(() => onErrorRef.current?.(resultOrError)); - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -347,7 +347,7 @@ export function useComposerAction({ tryCall(() => onErrorRef.current?.(error)); - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -362,7 +362,7 @@ export function useComposerAction({ return; } - onPostResponseToTargetRef.current( + onMessageRespondRef.current( { jsonrpc: "2.0", id: message.id, @@ -389,7 +389,7 @@ export function useComposerAction({ [ onCreateCastRef, onErrorRef, - onPostResponseToTargetRef, + onMessageRespondRef, onSignatureRef, onTransactionRef, resolveAddressRef, From 9208bcf104347e870026c7284c72b9e438516cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 19 Nov 2024 09:14:08 +0100 Subject: [PATCH 25/27] fix: types --- .../debugger/app/components/composer-form-action-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/debugger/app/components/composer-form-action-dialog.tsx b/packages/debugger/app/components/composer-form-action-dialog.tsx index 11f98d54a..68b18392b 100644 --- a/packages/debugger/app/components/composer-form-action-dialog.tsx +++ b/packages/debugger/app/components/composer-form-action-dialog.tsx @@ -144,7 +144,7 @@ export const ComposerFormActionDialog = ({ hash, }; }, - onPostResponseToTarget(message, form) { + onMessageRespond(message, form) { if (iframeRef.current && iframeRef.current.contentWindow) { iframeRef.current.contentWindow.postMessage( message, From b2d7f22dc8f3a9cff2fc8b6b51c7428b63f7378a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 19 Nov 2024 09:43:23 +0100 Subject: [PATCH 26/27] chore: use resolveAddress instead of connectedAddress --- .../debugger/app/components/cast-composer.tsx | 4 +- packages/render/src/unstable-types.ts | 15 +-- packages/render/src/unstable-use-frame.tsx | 92 ++++++++++--------- packages/render/src/use-composer-action.ts | 5 +- 4 files changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index e353259f1..53d8c7616 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -137,7 +137,9 @@ function CastEmbedPreview({ onRemove, url }: CastEmbedPreviewProps) { const farcasterIdentity = useFarcasterIdentity(); const frame = useFrame_unstable({ frameStateHook: useDebuggerFrameState, - connectedAddress: account.address, + async resolveAddress() { + return account.address ?? null; + }, homeframeUrl: url, frameActionProxy: "/frames", frameGetProxy: "/frames", diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index b6282cc5d..8433fed8f 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -21,7 +21,6 @@ import type { FrameGETRequest, FramePOSTRequest, FrameRequest, - OnConnectWalletFunc, OnMintArgs, OnSignatureFunc, OnTransactionFunc, @@ -49,6 +48,8 @@ export type ResolveSignerFunction = ( arg: ResolveSignerFunctionArg ) => ResolvedSigner; +export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; + export type UseFrameOptions< TExtraDataPending = unknown, TExtraDataDone = unknown, @@ -103,9 +104,13 @@ export type UseFrameOptions< */ frame?: ParseFramesWithReportsResult | null; /** - * connected wallet address of the user, send to the frame for transaction requests + * Called before onTransaction/onSignature is invoked to obtain an address to use. + * + * If the function returns null onTransaction/onSignature will not be called. + * + * Sent to the frame on transaction requests. */ - connectedAddress: `0x${string}` | undefined; + resolveAddress: ResolveAddressFunction; /** a function to handle mint buttons */ onMint?: (t: OnMintArgs) => void; /** a function to handle transaction buttons that returned transaction data from the target, returns the transaction hash or null */ @@ -114,10 +119,6 @@ export type UseFrameOptions< transactionDataSuffix?: `0x${string}`; /** A function to handle transaction buttons that returned signature data from the target, returns signature hash or null */ onSignature?: OnSignatureFunc; - /** - * Called when user presses transaction button but there is no wallet connected. - */ - onConnectWallet?: OnConnectWalletFunc; /** * Extra data appended to the frame action payload */ diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 9ae47b87e..6dbc65cc4 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -15,6 +15,11 @@ import type { UseFrameOptions, UseFrameReturnValue } from "./unstable-types"; import { useFrameState } from "./unstable-use-frame-state"; import { useFetchFrame } from "./unstable-use-fetch-frame"; import { useFreshRef } from "./hooks/use-fresh-ref"; +import { tryCallAsync } from "./helpers"; + +function onErrorFallback(e: Error): void { + console.error("@frames.js/render", e); +} function onMintFallback({ target }: OnMintArgs): void { console.log("Please provide your own onMint function to useFrame() hook."); @@ -28,7 +33,7 @@ function onMintFallback({ target }: OnMintArgs): void { } } -function onConnectWalletFallback(): never { +function resolveAddressFallback(): never { throw new Error( "Please implement this function in order to use transactions" ); @@ -131,6 +136,13 @@ function validateLinkButtonTarget(target: string): boolean { return true; } +function defaultFetchFunction( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + return fetch(input, init); +} + export type { UseFrameReturnValue as UnstableUseFrameReturnValue, UseFrameOptions as UnstableUseFrameOptions, @@ -149,9 +161,8 @@ export function useFrame_unstable< onMint = onMintFallback, onTransaction = onTransactionFallback, transactionDataSuffix, - onConnectWallet = onConnectWalletFallback, onSignature = onSignatureFallback, - connectedAddress, + resolveAddress = resolveAddressFallback, frame, /** Ex: /frames */ frameActionProxy, @@ -159,10 +170,10 @@ export function useFrame_unstable< frameGetProxy, extraButtonRequestPayload, resolveSigner: resolveSpecification, - onError, + onError = onErrorFallback, onLinkButtonClick = handleLinkButtonClickFallback, onRedirect = handleRedirectFallback, - fetchFn = (...args) => fetch(...args), + fetchFn = defaultFetchFunction, onTransactionDataError, onTransactionDataStart, onTransactionDataSuccess, @@ -246,7 +257,7 @@ export function useFrame_unstable< true ) .catch((e) => { - console.error(e); + onErrorRef.current(e instanceof Error ? e : new Error(String(e))); }); } else { resetFrameState({ @@ -254,7 +265,14 @@ export function useFrame_unstable< parseResult: frame, }); } - }, [frame, homeframeUrl, clearFrameState, fetchFrameRef, resetFrameState]); + }, [ + frame, + homeframeUrl, + clearFrameState, + fetchFrameRef, + resetFrameState, + onErrorRef, + ]); const onPostButton = useCallback( async function onPostButton({ @@ -281,8 +299,7 @@ export function useFrame_unstable< "Cannot perform post/post_redirect without a frame" ); - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); + onErrorRef.current(error); return; } @@ -314,8 +331,7 @@ export function useFrame_unstable< [fetchFrameRef, frameStateRef, onErrorRef] ); - const onConnectWalletRef = useFreshRef(onConnectWallet); - const connectedAddressRef = useFreshRef(connectedAddress); + const resolveAddressRef = useFreshRef(resolveAddress); const onTransactionButton = useCallback( async function onTransactionButton({ @@ -334,8 +350,7 @@ export function useFrame_unstable< if (currentState.type === "not-initialized") { const error = new Error("Cannot perform transaction without a frame"); - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); + onErrorRef.current(error); return; } @@ -346,25 +361,28 @@ export function useFrame_unstable< return; } - if (!connectedAddressRef.current) { - try { - onConnectWalletRef.current(); - } catch (e) { - onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); - console.error(`@frames.js/render: ${String(e)}`); - } + const addressOrError = await tryCallAsync(() => + resolveAddressRef.current() + ); + if (addressOrError instanceof Error) { + onErrorRef.current(addressOrError); + return; + } + + if (!addressOrError) { + onErrorRef.current( + new Error( + "Wallet address missing, please check resolveAddress() function passed to useFrame_unstable() hook." + ) + ); return; } // transaction request always requires address const frameContext = { ...currentState.frameContext, - address: - "address" in currentState.frameContext && - typeof currentState.frameContext.address === "string" - ? currentState.frameContext.address - : connectedAddressRef.current, + address: addressOrError, }; await fetchFrameRef.current({ @@ -376,7 +394,7 @@ export function useFrame_unstable< inputText: postInputText, signer: currentState.signerState.signer, frameContext, - address: connectedAddressRef.current, + address: addressOrError, url: currentState.homeframeUrl, target: frameButton.target, frameButton, @@ -386,13 +404,7 @@ export function useFrame_unstable< sourceFrame: currentFrame, }); }, - [ - frameStateRef, - connectedAddressRef, - fetchFrameRef, - onErrorRef, - onConnectWalletRef, - ] + [frameStateRef, fetchFrameRef, onErrorRef, resolveAddressRef] ); const onButtonPress = useCallback( @@ -408,7 +420,7 @@ export function useFrame_unstable< validateLinkButtonTarget(frameButton.target); } catch (error) { if (error instanceof Error) { - onErrorRef.current?.(error); + onErrorRef.current(error); } return; } @@ -446,7 +458,7 @@ export function useFrame_unstable< homeframeUrl; if (!target) { - onErrorRef.current?.(new Error(`Missing target`)); + onErrorRef.current(new Error(`Missing target`)); return; } @@ -454,7 +466,7 @@ export function useFrame_unstable< validateLinkButtonTarget(target); } catch (error) { if (error instanceof Error) { - onErrorRef.current?.(error); + onErrorRef.current(error); } return; } @@ -479,11 +491,9 @@ export function useFrame_unstable< }); setInputText(""); } catch (err) { - if (err instanceof Error) { - onErrorRef.current?.(err); - } - - console.error(err); + onErrorRef.current( + err instanceof Error ? err : new Error(String(err)) + ); } break; } diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts index 2443cdadb..209a1550b 100644 --- a/packages/render/src/use-composer-action.ts +++ b/packages/render/src/use-composer-action.ts @@ -23,8 +23,9 @@ import type { } from "./mini-app-messages"; import { miniAppMessageSchema } from "./mini-app-messages"; import type { FarcasterSigner } from "./identity/farcaster"; +import type { ResolveAddressFunction } from "./unstable-types"; -export type { MiniAppMessage, MiniAppResponse }; +export type { MiniAppMessage, MiniAppResponse, ResolveAddressFunction }; type FetchComposerActionFunctionArg = { actionState: ComposerActionState; @@ -68,8 +69,6 @@ export type OnCreateCastFunction = (arg: { cast: ComposerActionState; }) => Promise; -export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; - export type OnMessageRespondFunction = ( response: MiniAppResponse, form: ComposerActionFormResponse From 49c1f7ebc0774dfde49d53c1842cca9ec404fde2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 19 Nov 2024 10:11:05 +0100 Subject: [PATCH 27/27] chore: move debugger frame state hook to debugger --- packages/debugger/app/components/cast-composer.tsx | 2 +- .../app/hooks/useDebuggerFrameState.ts} | 4 ++-- packages/render/package.json | 10 ---------- 3 files changed, 3 insertions(+), 13 deletions(-) rename packages/{render/src/unstable-use-debugger-frame-state.ts => debugger/app/hooks/useDebuggerFrameState.ts} (96%) diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 53d8c7616..957a48bae 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -18,10 +18,10 @@ import { FrameUI } from "./frame-ui"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; -import { useDebuggerFrameState } from "@frames.js/render/unstable-use-debugger-frame-state"; import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; import { useAccount } from "wagmi"; import { FrameStackDone } from "@frames.js/render/unstable-types"; +import { useDebuggerFrameState } from "../hooks/useDebuggerFrameState"; type CastComposerProps = { composerAction: Partial; diff --git a/packages/render/src/unstable-use-debugger-frame-state.ts b/packages/debugger/app/hooks/useDebuggerFrameState.ts similarity index 96% rename from packages/render/src/unstable-use-debugger-frame-state.ts rename to packages/debugger/app/hooks/useDebuggerFrameState.ts index 349949f72..554343953 100644 --- a/packages/render/src/unstable-use-debugger-frame-state.ts +++ b/packages/debugger/app/hooks/useDebuggerFrameState.ts @@ -2,8 +2,8 @@ import type { FrameState, FrameStateAPI, UseFrameStateOptions, -} from "./unstable-types"; -import { useFrameState } from "./unstable-use-frame-state"; +} from "@frames.js/render/unstable-types"; +import { useFrameState } from "@frames.js/render/unstable-use-frame-state"; function computeDurationInSeconds(start: Date, end: Date): number { return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); diff --git a/packages/render/package.json b/packages/render/package.json index 41330a3e7..fe85535c1 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -140,16 +140,6 @@ "default": "./dist/use-composer-action.cjs" } }, - "./unstable-use-debugger-frame-state": { - "import": { - "types": "./dist/unstable-use-debugger-frame-state.d.ts", - "default": "./dist/unstable-use-debugger-frame-state.js" - }, - "require": { - "types": "./dist/unstable-use-debugger-frame-state.d.cts", - "default": "./dist/unstable-use-debugger-frame-state.cjs" - } - }, "./unstable-use-frame-state": { "import": { "types": "./dist/unstable-use-frame-state.d.ts",