From c0e405588b03c94f0afa00821c76f2398b54b879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 22 Oct 2024 14:40:06 +0200 Subject: [PATCH 01/11] feat: parse all specs at once --- packages/frames.js/package.json | 10 +++++ packages/frames.js/src/frame-parsers/types.ts | 17 ++++++++ packages/frames.js/src/getFrame.ts | 20 ++------- .../frames.js/src/parseFramesWithReports.ts | 43 +++++++++---------- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/packages/frames.js/package.json b/packages/frames.js/package.json index a51fcf04f..e6bfb4c2b 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/types.ts b/packages/frames.js/src/frame-parsers/types.ts index b815105d5..056ce56c4 100644 --- a/packages/frames.js/src/frame-parsers/types.ts +++ b/packages/frames.js/src/frame-parsers/types.ts @@ -60,3 +60,20 @@ export type ParseResult = */ reports: Record; }; + +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.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.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, + }, + }; } From 8c84540363c487635f66d9572f2949ba04201e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 22 Oct 2024 15:33:39 +0200 Subject: [PATCH 02/11] feat: render multi protocol support with dynamic signers --- packages/render/package.json | 10 + packages/render/src/collapsed-frame-ui.tsx | 59 +-- packages/render/src/fallback-frame-context.ts | 2 +- packages/render/src/farcaster/frames.tsx | 27 +- packages/render/src/farcaster/index.ts | 1 + packages/render/src/farcaster/signers.tsx | 16 +- packages/render/src/farcaster/types.ts | 5 + packages/render/src/frame-ui.tsx | 93 +++-- packages/render/src/helpers.ts | 150 ++++++++ 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 | 226 ++++++------ packages/render/src/ui/frame.base.tsx | 65 +++- packages/render/src/ui/types.ts | 11 +- packages/render/src/ui/utils.ts | 42 --- packages/render/src/use-fetch-frame.ts | 170 ++++----- packages/render/src/use-frame-stack.ts | 137 +++---- packages/render/src/use-frame.tsx | 348 +++++++++++------- 21 files changed, 956 insertions(+), 654 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 delete mode 100644 packages/render/src/ui/utils.ts diff --git a/packages/render/package.json b/packages/render/package.json index 340641091..b6e58eb2f 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -76,6 +76,16 @@ "default": "./dist/farcaster/index.cjs" } }, + "./helpers": { + "import": { + "types": "./dist/helpers.d.ts", + "default": "./dist/helpers.js" + }, + "require": { + "types": "./dist/helpers.d.cts", + "default": "./dist/helpers.cjs" + } + }, "./ui": { "react-native": { "types": "./dist/ui/index.native.d.cts", diff --git a/packages/render/src/collapsed-frame-ui.tsx b/packages/render/src/collapsed-frame-ui.tsx index 661c8e6ca..e7fdda69e 100644 --- a/packages/render/src/collapsed-frame-ui.tsx +++ b/packages/render/src/collapsed-frame-ui.tsx @@ -2,6 +2,10 @@ import type { ImgHTMLAttributes } from "react"; import React, { useState } from "react"; import type { Frame } from "frames.js"; import type { FrameTheme, FrameState } from "./types"; +import { + getFrameParseResultFromStackItemBySpecifications, + isPartialFrameParseResult, +} from "./helpers"; const defaultTheme: Required = { buttonBg: "#fff", @@ -20,7 +24,7 @@ const getThemeWithDefaults = (theme: FrameTheme): FrameTheme => { }; export type CollapsedFrameUIProps = { - frameState: FrameState; + frameState: FrameState; theme?: FrameTheme; FrameImage?: React.FC & { src: string }>; allowPartialFrame?: boolean; @@ -34,44 +38,51 @@ export function CollapsedFrameUI({ allowPartialFrame, }: CollapsedFrameUIProps): React.JSX.Element | null { const [isImageLoading, setIsImageLoading] = useState(true); - const currentFrame = frameState.currentFrameStackItem; - const isLoading = currentFrame?.status === "pending" || isImageLoading; + const { currentFrameStackItem, specifications } = frameState; + const isLoading = + currentFrameStackItem?.status === "pending" || isImageLoading; const resolvedTheme = getThemeWithDefaults(theme ?? {}); if (!frameState.homeframeUrl) { return null; } - if (!currentFrame) { + if (!currentFrameStackItem) { return null; } - if ( - currentFrame.status === "done" && - currentFrame.frameResult.status === "failure" && - !( - allowPartialFrame && - // Need at least image and buttons to render a partial frame - currentFrame.frameResult.frame.image && - currentFrame.frameResult.frame.buttons - ) - ) { - return null; + if (currentFrameStackItem.status === "done") { + const currentParseResult = getFrameParseResultFromStackItemBySpecifications( + currentFrameStackItem, + specifications + ); + + if ( + currentParseResult.status === "failure" && + (!allowPartialFrame || !isPartialFrameParseResult(currentParseResult)) + ) { + return null; + } } let frame: Frame | Partial | undefined; - if (currentFrame.status === "done") { - frame = currentFrame.frameResult.frame; + if (currentFrameStackItem.status === "done") { + const currentParseResult = getFrameParseResultFromStackItemBySpecifications( + currentFrameStackItem, + specifications + ); + + frame = currentParseResult.frame; } else if ( - currentFrame.status === "message" || - currentFrame.status === "doneRedirect" + currentFrameStackItem.status === "message" || + currentFrameStackItem.status === "doneRedirect" ) { - frame = currentFrame.request.sourceFrame; - } else if (currentFrame.status === "requestError") { + frame = currentFrameStackItem.request.sourceFrame; + } else if (currentFrameStackItem.status === "requestError") { frame = - "sourceFrame" in currentFrame.request - ? currentFrame.request.sourceFrame + "sourceFrame" in currentFrameStackItem.request + ? currentFrameStackItem.request.sourceFrame : undefined; } @@ -118,7 +129,7 @@ export function CollapsedFrameUI({
{frame?.title} - {new URL(currentFrame.url).hostname} + {new URL(currentFrameStackItem.url).hostname}
{!!frame && !!frame.buttons ? ( 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..a0925fbce 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, @@ -179,13 +176,9 @@ function isFarcasterFrameContext( /** * Used to create an unsigned frame action when signer is not defined */ -export async function unsignedFrameAction< - TSignerStorageType = Record, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, ->( - actionContext: SignerStateActionContext -): Promise> { +export async function unsignedFrameAction( + actionContext: SignerStateActionContext +): Promise { const { frameButton, target, @@ -232,6 +225,6 @@ export async function unsignedFrameAction< trustedData: { messageBytes: Buffer.from("").toString("hex"), }, - } as unknown as TFrameActionBodyType, + }, }); } 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..5740446c5 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,7 +1,17 @@ -import type { SignerStateInstance } from ".."; +import type { + AllowedStorageTypes, + FrameActionBodyPayload, + SignerStateInstance, +} from "../types"; +import type { FarcasterFrameContext } from "./types"; -export type FarcasterSignerState = - SignerStateInstance; +export type FarcasterSignerState< + TSignerType extends AllowedStorageTypes = FarcasterSigner | null, +> = 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/frame-ui.tsx b/packages/render/src/frame-ui.tsx index b13e09d44..916afac4d 100644 --- a/packages/render/src/frame-ui.tsx +++ b/packages/render/src/frame-ui.tsx @@ -1,12 +1,12 @@ import type { ImgHTMLAttributes } from "react"; import React, { useState } from "react"; import type { Frame, FrameButton } from "frames.js"; -import type { - FrameTheme, - FrameState, - FrameStackMessage, - FrameStackRequestError, -} from "./types"; +import type { FrameTheme, FrameState } from "./types"; +import { + getErrorMessageFromFramesStackItem, + getFrameParseResultFromStackItemBySpecifications, + isPartialFrameParseResult, +} from "./helpers"; export const defaultTheme: Required = { buttonBg: "#fff", @@ -90,22 +90,8 @@ function MessageTooltip({ ); } -function getErrorMessageFromFramesStackItem( - item: FrameStackMessage | FrameStackRequestError -): string { - if (item.status === "message") { - return item.message; - } - - if (item.requestError instanceof Error) { - return item.requestError.message; - } - - return "An error occurred"; -} - export type FrameUIProps = { - frameState: FrameState; + frameState: FrameState; theme?: FrameTheme; FrameImage?: React.FC & { src: string }>; allowPartialFrame?: boolean; @@ -126,8 +112,9 @@ export function FrameUI({ enableImageDebugging, }: FrameUIProps): React.JSX.Element | null { const [isImageLoading, setIsImageLoading] = useState(true); - const currentFrame = frameState.currentFrameStackItem; - const isLoading = currentFrame?.status === "pending" || isImageLoading; + const { currentFrameStackItem, specifications } = frameState; + const isLoading = + currentFrameStackItem?.status === "pending" || isImageLoading; const resolvedTheme = getThemeWithDefaults(theme ?? {}); if (!frameState.homeframeUrl) { @@ -136,40 +123,50 @@ export function FrameUI({ ); } - if (!currentFrame) { + if (!currentFrameStackItem) { return null; } - if ( - currentFrame.status === "done" && - currentFrame.frameResult.status === "failure" && - !( - allowPartialFrame && - // Need at least image and buttons to render a partial frame - currentFrame.frameResult.frame.image && - currentFrame.frameResult.frame.buttons - ) - ) { - return ; + // check if frame is partial and if partials are allowed + if (currentFrameStackItem.status === "done") { + const currentFrameParseResult = + getFrameParseResultFromStackItemBySpecifications( + currentFrameStackItem, + specifications + ); + + // not a proper partial frame or partial frames are disabled + if ( + currentFrameParseResult.status === "failure" && + (!allowPartialFrame || + !isPartialFrameParseResult(currentFrameParseResult)) + ) { + return ; + } } let frame: Frame | Partial | undefined; let debugImage: string | undefined; - if (currentFrame.status === "done") { - frame = currentFrame.frameResult.frame; + if (currentFrameStackItem.status === "done") { + const parseResult = getFrameParseResultFromStackItemBySpecifications( + currentFrameStackItem, + specifications + ); + + frame = parseResult.frame; debugImage = enableImageDebugging - ? currentFrame.frameResult.framesDebugInfo?.image + ? parseResult.framesDebugInfo?.image : undefined; } else if ( - currentFrame.status === "message" || - currentFrame.status === "doneRedirect" + currentFrameStackItem.status === "message" || + currentFrameStackItem.status === "doneRedirect" ) { - frame = currentFrame.request.sourceFrame; - } else if (currentFrame.status === "requestError") { + frame = currentFrameStackItem.request.sourceFrame; + } else if (currentFrameStackItem.status === "requestError") { frame = - "sourceFrame" in currentFrame.request - ? currentFrame.request.sourceFrame + "sourceFrame" in currentFrameStackItem.request + ? currentFrameStackItem.request.sourceFrame : undefined; } @@ -182,11 +179,13 @@ export function FrameUI({
{" "} {/* Ensure the container fills the height */} - {currentFrame.status === "message" ? ( + {currentFrameStackItem.status === "message" ? ( ) : null} {!!frame && !!frame.image && ( diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index c4b5b0c46..1f2c89f56 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -1,3 +1,21 @@ +import type { + Frame, + GetFrameResult, + SupportedParsingSpecification, +} from "frames.js"; +import type { + ParseFramesWithReportsResult, + ParseResult, + ParseResultWithFrameworkDetails, +} from "frames.js/frame-parsers"; +import type { PartialFrame } from "./ui/types"; +import type { + FramesStackItem, + FrameStackDone, + FrameStackMessage, + FrameStackRequestError, +} from "./types"; + export async function tryCallAsync( promiseFn: () => Promise ): Promise { @@ -25,3 +43,135 @@ 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 + ); +} + +function isParseResult(value: unknown): value is ParseResult { + return ( + typeof value === "object" && + value !== null && + "status" in value && + "frame" in value + ); +} + +export function createParseFramesWithReportsObject( + input: Frame | ParseResult +): ParseFramesWithReportsResult { + if (isParseResult(input)) { + if ("accepts" in input.frame || "accepts" in input.reports) { + // this is open frame + return { + openframes: input, + farcaster: { status: "failure", frame: input.frame, reports: {} }, + }; + } + + return { + farcaster: input, + openframes: + "accepts" in input.frame || "accepts" in input.reports + ? input + : { status: "failure", frame: input.frame, reports: {} }, + }; + } + + return { + // always treat the frame as farcaster frame + farcaster: { status: "success", frame: input, reports: {} }, + openframes: + // detect if it is a valid openframe + !input.accepts || input.accepts.length === 0 + ? { status: "failure", frame: input, reports: {} } + : { status: "success", frame: input, reports: {} }, + }; +} + +type FailedParseResultWithFrameworkDetails = Exclude< + ParseResultWithFrameworkDetails, + { status: "success" } +>; + +type ParseResultWithFrameworkDetailsWithPartialFrame = Omit< + FailedParseResultWithFrameworkDetails, + "frame" +> & { + frame: PartialFrame; +}; + +export function isPartialFrameParseResult( + parseResult: ParseResultWithFrameworkDetails +): parseResult is ParseResultWithFrameworkDetailsWithPartialFrame { + return ( + parseResult.status === "failure" && + !!parseResult.frame.image && + !!parseResult.frame.buttons && + parseResult.frame.buttons.length > 0 + ); +} + +export function getErrorMessageFromFramesStackItem( + item: FrameStackMessage | FrameStackRequestError +): string { + if (item.status === "message") { + return item.message; + } + + if (item.requestError instanceof Error) { + return item.requestError.message; + } + + return "An error occurred"; +} + +export function getDoneStackItemFromStackItem( + stackItem: FramesStackItem | undefined +): FrameStackDone | undefined { + if (!stackItem || stackItem.status !== "done") { + return undefined; + } + + return undefined; +} + +export function getFrameParseResultFromStackItemBySpecifications( + stackItem: FrameStackDone, + specifications: SupportedParsingSpecification[] +): GetFrameResult { + // find valid parse result for given specification or fallback to any valid parse result + for (const specification of specifications) { + if (stackItem.parseResult[specification].status === "success") { + return stackItem.parseResult[specification]; + } + } + + const [specification] = specifications; + + if (!specification) { + throw new Error("No specification provided"); + } + + // return the parse result even if it is invalid, because there was nothing to fall back to + return stackItem.parseResult[specification]; +} + +export function getFrameFromStackItemBySpecification( + stackItem: FrameStackDone, + specifications: SupportedParsingSpecification[] +): undefined | Frame | Partial { + const result = getFrameParseResultFromStackItemBySpecifications( + stackItem, + specifications + ); + + return result.frame; +} 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 16b39b520..509363287 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -8,16 +8,17 @@ import type { TransactionTargetResponse, TransactionTargetResponseSendTransaction, TransactionTargetResponseSignTypedDataV4, - getFrame, } from "frames.js"; import type { Dispatch } from "react"; -import type { ParseResult } from "frames.js/frame-parsers"; +import type { + ParseFramesWithReportsResult, + ParseResult, +} from "frames.js/frame-parsers"; import type { CastActionResponse, ComposerActionFormResponse, ComposerActionState, } from "frames.js/types"; -import type { FarcasterFrameContext } from "./farcaster/frames"; import type { FrameStackAPI } from "./use-frame-stack"; export type OnTransactionArgs = { @@ -72,35 +73,23 @@ export type OnConnectWalletFunc = () => void; * Used to sign frame action */ export type SignFrameActionFunc< - TSignerStorageType = Record, + TSignerStorageType extends AllowedStorageTypes = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TFrameContextType extends FrameContext = FrameContext, > = ( actionContext: SignerStateActionContext ) => Promise>; -export type UseFetchFrameSignFrameActionFunction< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - >, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, -> = (arg: { - actionContext: TSignerStateActionContext; +export type UseFetchFrameSignFrameActionFunction = (arg: { + actionContext: SignerStateActionContext; /** * @defaultValue false */ forceRealSigner?: boolean; -}) => Promise>; +}) => Promise; -export type UseFetchFrameOptions< - TSignerStorageType = Record, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, -> = { +export type UseFetchFrameOptions = { stackAPI: FrameStackAPI; - stackDispatch: React.Dispatch; - specification: SupportedParsingSpecification; /** * URL or path to the frame proxy handling GET requests. */ @@ -113,10 +102,7 @@ export type UseFetchFrameOptions< * Extra payload to be sent with the POST request. */ extraButtonRequestPayload?: Record; - signFrameAction: UseFetchFrameSignFrameActionFunction< - SignerStateActionContext, - TFrameActionBodyType - >; + signFrameAction: UseFetchFrameSignFrameActionFunction; /** * Called after transaction data has been returned from the server and user needs to approve the transaction. */ @@ -209,27 +195,81 @@ export type UseFetchFrameOptions< onTransactionProcessingError?: (error: Error) => void; }; +export type ResolveFrameActionContextArgument = { + readonly dangerouslySkipSigning: boolean; + readonly frameStack: FramesStack; + readonly specifications: SupportedParsingSpecification[]; +}; + +export type ResolveFrameActionContextResult = { + signerState: SignerStateInstance; + frameContext: FrameContext; +}; + +export type ResolveFrameActionContextFunction = ( + arg: ResolveFrameActionContextArgument +) => Promise; + +export type AllowedStorageTypes = Record | null; + export type UseFrameOptions< - TSignerStorageType = Record, + TSignerStorageType extends AllowedStorageTypes = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TFrameContextType extends FrameContext = FrameContext, > = { - /** skip frame signing, for frames that don't verify signatures */ + /** + * skip frame signing, for frames that don't verify signatures + * + * @defaultValue false + */ dangerousSkipSigning?: boolean; /** 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; - /** an signer state object used to determine what actions are possible */ + /** + * The url of the homeframe, + * + * if null / undefined won't load a frame nor show the initial frame. + * + * This value should be memoized otherwise it will reset frame state. + */ + homeframeUrl: string | null | undefined; + /** + * The initial frame. if not specified will fetch it from the homeframeUrl prop. + * + * This value should be immutable otherwise it will reset frame state. + */ + frame?: Frame | ParseResult | ParseFramesWithReportsResult; + /** + * Which specification to use for parsing the frame action payload. Can be provided as a tuple to define the preferred and fallback specification. + * + * @defaultValue 'farcaster' + */ + specification?: + | SupportedParsingSpecification + | [SupportedParsingSpecification, SupportedParsingSpecification]; + /** + * A signer state object used to determine what actions are possible. + * + * This is a default signer that will be used unless you provide resolveActionContext function. + */ signerState: SignerStateInstance< TSignerStorageType, TFrameActionBodyType, TFrameContextType >; - /** the url of the homeframe, if null / undefined won't load a frame */ - homeframeUrl: string | null | undefined; - /** the initial frame. if not specified will fetch it from the homeframeUrl prop */ - frame?: Frame | ParseResult; + /** + * the context of this frame, used for generating Frame Action payloads + */ + frameContext: TFrameContextType; + /** + * The function called on each button press to resolve which signer and context to use + * based on current state and parsed frames. + * + * If no function is provided it will use signerState and frameContext props. + */ + resolveActionContext?: ResolveFrameActionContextFunction; /** * connected wallet address of the user, send to the frame for transaction requests */ @@ -246,18 +286,10 @@ export type UseFrameOptions< * Called when user presses transaction button but there is no wallet connected. */ onConnectWallet?: OnConnectWalletFunc; - /** the context of this frame, used for generating Frame Action payloads */ - frameContext: TFrameContextType; /** * Extra data appended to the frame action payload */ extraButtonRequestPayload?: Record; - /** - * Which specification to use for parsing the frame action payload - * - * @defaultValue 'farcaster' - */ - specification?: SupportedParsingSpecification; /** * This function can be used to customize how error is reported to the user. */ @@ -285,8 +317,8 @@ export type UseFrameOptions< >; type SignerStateActionSharedContext< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TSignerStorageType extends AllowedStorageTypes = Record, + TFrameContextType extends FrameContext = FrameContext, > = { target?: string; frameButton: FrameButton; @@ -302,15 +334,15 @@ type SignerStateActionSharedContext< }; export type SignerStateDefaultActionContext< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TSignerStorageType extends AllowedStorageTypes = Record, + TFrameContextType extends FrameContext = FrameContext, > = { type?: "default"; } & SignerStateActionSharedContext; export type SignerStateTransactionDataActionContext< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TSignerStorageType extends AllowedStorageTypes = Record, + TFrameContextType extends FrameContext = FrameContext, > = { type: "tx-data"; /** Wallet address used to create the transaction, available only for "tx" button actions */ @@ -318,8 +350,8 @@ export type SignerStateTransactionDataActionContext< } & SignerStateActionSharedContext; export type SignerStateTransactionPostActionContext< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TSignerStorageType extends AllowedStorageTypes = Record, + TFrameContextType extends FrameContext = FrameContext, > = { type: "tx-post"; /** Wallet address used to create the transaction, available only for "tx" button actions */ @@ -328,8 +360,8 @@ export type SignerStateTransactionPostActionContext< } & SignerStateActionSharedContext; export type SignerStateActionContext< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TSignerStorageType extends AllowedStorageTypes = Record, + TFrameContextType extends FrameContext = FrameContext, > = | SignerStateDefaultActionContext | SignerStateTransactionDataActionContext< @@ -349,19 +381,17 @@ export type SignedFrameAction< }; export type SignFrameActionFunction< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, + TSignerStateActionContext extends + SignerStateActionContext = SignerStateActionContext, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, > = ( actionContext: TSignerStateActionContext ) => Promise>; export interface SignerStateInstance< - TSignerStorageType = Record, + TSignerStorageType extends AllowedStorageTypes = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, + TFrameContextType extends FrameContext = FrameContext, > { signer: TSignerStorageType | null; /** @@ -384,17 +414,12 @@ export type FrameGETRequest = { url: string; }; -export type FramePOSTRequest< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, -> = +export type FramePOSTRequest = | { method: "POST"; source?: never; frameButton: FrameButtonPost | FrameButtonTx; - signerStateActionContext: TSignerStateActionContext; + signerStateActionContext: SignerStateActionContext; isDangerousSkipSigning: boolean; /** * The frame that was the source of the button press. @@ -405,17 +430,12 @@ export type FramePOSTRequest< method: "POST"; source: "cast-action" | "composer-action"; frameButton: FrameButtonPost | FrameButtonTx; - signerStateActionContext: TSignerStateActionContext; + signerStateActionContext: SignerStateActionContext; isDangerousSkipSigning: boolean; sourceFrame: undefined; }; -export type FrameRequest< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, -> = FrameGETRequest | FramePOSTRequest; +export type FrameRequest = FrameGETRequest | FramePOSTRequest; export type FrameStackBase = { timestamp: Date; @@ -456,12 +476,10 @@ export type FrameStackGetPending = { export type FrameStackPending = FrameStackGetPending | FrameStackPostPending; -export type GetFrameResult = ReturnType; - export type FrameStackDone = FrameStackBase & { request: FrameRequest; response: Response; - frameResult: GetFrameResult; + parseResult: ParseFramesWithReportsResult; status: "done"; }; @@ -519,20 +537,15 @@ export type FrameReducerActions = | { action: "CLEAR" } | { action: "RESET_INITIAL_FRAME"; - resultOrFrame: ParseResult | Frame; - homeframeUrl: string | null | undefined; + parseResult: ParseFramesWithReportsResult; + homeframeUrl: string; }; -type ButtonPressFunction< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - >, -> = ( +export type ButtonPressFunction = ( frame: Frame, frameButton: FrameButton, index: number, - fetchFrameOverride?: FetchFrameFunction + fetchFrameOverride?: FetchFrameFunction ) => void | Promise; type CastActionButtonPressFunctionArg = { @@ -566,12 +579,7 @@ export type ComposerActionButtonPressFunction = ( arg: ComposerActionButtonPressFunctionArg ) => Promise; -export type CastActionRequest< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, -> = Omit< +export type CastActionRequest = Omit< FramePOSTRequest, "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" > & { @@ -580,17 +588,12 @@ export type CastActionRequest< url: string; }; signerStateActionContext: Omit< - FramePOSTRequest["signerStateActionContext"], + FramePOSTRequest["signerStateActionContext"], "frameButton" | "inputText" | "state" >; }; -export type ComposerActionRequest< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, -> = Omit< +export type ComposerActionRequest = Omit< FramePOSTRequest, "method" | "frameButton" | "sourceFrame" | "signerStateActionContext" > & { @@ -600,21 +603,13 @@ export type ComposerActionRequest< }; composerActionState: ComposerActionState; signerStateActionContext: Omit< - FramePOSTRequest["signerStateActionContext"], + FramePOSTRequest["signerStateActionContext"], "frameButton" | "inputText" | "state" >; }; -export type FetchFrameFunction< - TSignerStateActionContext extends SignerStateActionContext< - unknown, - Record - > = SignerStateActionContext, -> = ( - request: - | FrameRequest - | CastActionRequest - | ComposerActionRequest, +export type FetchFrameFunction = ( + request: FrameRequest | CastActionRequest | ComposerActionRequest, /** * If true, the frame stack will be cleared before the new frame is loaded * @@ -623,13 +618,8 @@ export type FetchFrameFunction< shouldClear?: boolean ) => Promise; -export type FrameState< - TSignerStorageType = Record, - TFrameContextType extends FrameContext = FarcasterFrameContext, -> = { - fetchFrame: FetchFrameFunction< - SignerStateActionContext - >; +export type FrameState = { + fetchFrame: FetchFrameFunction; clearFrameStack: () => void; dispatchFrameStack: Dispatch; /** The frame at the top of the stack (at index 0) */ @@ -638,12 +628,14 @@ export type FrameState< framesStack: FramesStack; inputText: string; setInputText: (s: string) => void; - onButtonPress: ButtonPressFunction< - SignerStateActionContext - >; + onButtonPress: ButtonPressFunction; + /** + * If this is not a non empty string then currentFrameStackItem is undefined as the frame state is disabled. + */ homeframeUrl: string | null | undefined; onCastActionButtonPress: CastActionButtonPressFunction; onComposerActionButtonPress: ComposerActionButtonPressFunction; + specifications: SupportedParsingSpecification[]; }; export type OnMintArgs = { diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx index 609cdc730..b2020037a 100644 --- a/packages/render/src/ui/frame.base.tsx +++ b/packages/render/src/ui/frame.base.tsx @@ -6,7 +6,13 @@ import { useRef, useState, } from "react"; -import type { FrameStackDone, FrameState } from "../types"; +import type { FrameState } from "../types"; +import { + getDoneStackItemFromStackItem, + getErrorMessageFromFramesStackItem, + getFrameParseResultFromStackItemBySpecifications, + isPartialFrameParseResult, +} from "../helpers"; import type { FrameMessage, FrameUIComponents as BaseFrameUIComponents, @@ -14,11 +20,8 @@ import type { FrameUIState, RootContainerDimensions, RootContainerElement, + PartialFrame, } from "./types"; -import { - getErrorMessageFromFramesStackItem, - isPartialFrameStackItem, -} from "./utils"; export type FrameUIComponents> = Partial>; @@ -27,7 +30,7 @@ export type FrameUITheme> = Partial>; export type BaseFrameUIProps> = { - frameState: FrameState; + frameState: FrameState; /** * Renders also frames that contain only image and at least one button * @@ -123,9 +126,25 @@ export function BaseFrameUI>({ } let frameUiState: FrameUIState; - const previousFrame = ( - frameState.framesStack[frameState.framesStack.length - 1] as FrameStackDone - )?.frameResult?.frame; + const previousDoneStackItem = getDoneStackItemFromStackItem( + // at 0 is always current frame, so we are interested in previous frame + frameState.framesStack[1] + ); + const previousParseResult = previousDoneStackItem + ? getFrameParseResultFromStackItemBySpecifications( + previousDoneStackItem, + frameState.specifications + ) + : undefined; + let previousFrame: undefined | Frame | PartialFrame; + + if ( + previousParseResult && + (previousParseResult.status === "success" || + (isPartialFrameParseResult(previousParseResult) && allowPartialFrame)) + ) { + previousFrame = previousParseResult.frame; + } switch (currentFrameStackItem.status) { case "requestError": { @@ -136,6 +155,7 @@ export function BaseFrameUI>({ frameUiState = { status: "complete", frame: currentFrameStackItem.request.sourceFrame, + previousFrame, isImageLoading, id: currentFrameStackItem.timestamp.getTime(), frameState, @@ -168,6 +188,7 @@ export function BaseFrameUI>({ frameUiState = { status: "complete", frame: currentFrameStackItem.request.sourceFrame, + previousFrame, isImageLoading, id: currentFrameStackItem.timestamp.getTime(), frameState, @@ -180,11 +201,13 @@ export function BaseFrameUI>({ status: "loading", id: currentFrameStackItem.timestamp.getTime(), frameState, + previousFrame, }; } else { frameUiState = { status: "complete", frame: currentFrameStackItem.request.sourceFrame, + previousFrame, isImageLoading, id: currentFrameStackItem.timestamp.getTime(), frameState, @@ -194,26 +217,30 @@ export function BaseFrameUI>({ break; } case "done": { - if (currentFrameStackItem.frameResult.status === "success") { + const parseResult = getFrameParseResultFromStackItemBySpecifications( + currentFrameStackItem, + frameState.specifications + ); + + if (parseResult.status === "success") { frameUiState = { status: "complete", - frame: currentFrameStackItem.frameResult.frame, + frame: parseResult.frame, + previousFrame, debugImage: enableImageDebugging - ? currentFrameStackItem.frameResult.framesDebugInfo?.image + ? parseResult.framesDebugInfo?.image : undefined, isImageLoading, id: currentFrameStackItem.timestamp.getTime(), frameState, }; - } else if ( - isPartialFrameStackItem(currentFrameStackItem) && - allowPartialFrame - ) { + } else if (isPartialFrameParseResult(parseResult) && allowPartialFrame) { frameUiState = { status: "partial", - frame: currentFrameStackItem.frameResult.frame, + frame: parseResult.frame, + previousFrame, debugImage: enableImageDebugging - ? currentFrameStackItem.frameResult.framesDebugInfo?.image + ? parseResult.framesDebugInfo?.image : undefined, isImageLoading, id: currentFrameStackItem.timestamp.getTime(), @@ -231,6 +258,7 @@ export function BaseFrameUI>({ case "pending": { frameUiState = { status: "loading", + previousFrame, id: currentFrameStackItem.timestamp.getTime(), frameState, }; @@ -351,7 +379,6 @@ export function BaseFrameUI>({ frame: frameUiState, textInput: components.TextInput( { - // @TODO provide previous frame to pending state so we can remove loading check and render skeletons? isDisabled: false, frameState: frameUiState, placeholder: frameUiState.frame.inputText, diff --git a/packages/render/src/ui/types.ts b/packages/render/src/ui/types.ts index 059149367..c1373c0f6 100644 --- a/packages/render/src/ui/types.ts +++ b/packages/render/src/ui/types.ts @@ -6,7 +6,7 @@ import type { FrameState } from "../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,11 +30,17 @@ export type PartialFrame = Omit, RequiredFrameProperties> & Required>; export type FrameUIState = - | { status: "loading"; id: number; frameState: FrameState } + | { + status: "loading"; + id: number; + frameState: FrameState; + previousFrame: Frame | PartialFrame | undefined; + } | { id: number; status: "partial"; frame: PartialFrame; + previousFrame: Frame | PartialFrame | undefined; frameState: FrameState; debugImage?: string; isImageLoading: boolean; @@ -43,6 +49,7 @@ export type FrameUIState = id: number; status: "complete"; frame: Frame; + previousFrame: Frame | PartialFrame | undefined; frameState: FrameState; debugImage?: string; isImageLoading: boolean; diff --git a/packages/render/src/ui/utils.ts b/packages/render/src/ui/utils.ts deleted file mode 100644 index c3a6db5c3..000000000 --- a/packages/render/src/ui/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - FramesStackItem, - FrameStackDone, - GetFrameResult, - FrameStackMessage, - FrameStackRequestError, -} from "../types"; -import type { PartialFrame } from "./types"; - -type FrameResultFailure = Exclude; - -type FrameStackItemWithPartialFrame = Omit & { - frameResult: Omit & { - frame: PartialFrame; - }; -}; - -export function isPartialFrameStackItem( - stackItem: FramesStackItem -): stackItem is FrameStackItemWithPartialFrame { - return ( - stackItem.status === "done" && - stackItem.frameResult.status === "failure" && - !!stackItem.frameResult.frame.image && - !!stackItem.frameResult.frame.buttons && - stackItem.frameResult.frame.buttons.length > 0 - ); -} - -export function getErrorMessageFromFramesStackItem( - item: FrameStackMessage | FrameStackRequestError -): string { - if (item.status === "message") { - return item.message; - } - - if (item.requestError instanceof Error) { - return item.requestError.message; - } - - return "An error occurred"; -} diff --git a/packages/render/src/use-fetch-frame.ts b/packages/render/src/use-fetch-frame.ts index 32a76465f..3466f16e0 100644 --- a/packages/render/src/use-fetch-frame.ts +++ b/packages/render/src/use-fetch-frame.ts @@ -1,9 +1,5 @@ /* eslint-disable no-console -- provide feedback to console */ -import type { - FrameButtonPost, - SupportedParsingSpecification, - TransactionTargetResponse, -} from "frames.js"; +import type { FrameButtonPost, TransactionTargetResponse } from "frames.js"; import type { types } from "frames.js/core"; import type { CastActionFrameResponse, @@ -12,25 +8,20 @@ import type { ErrorMessageResponse, } from "frames.js/types"; import { hexToBytes } from "viem"; -import type { FarcasterFrameContext } from "./farcaster"; import type { CastActionRequest, ComposerActionRequest, FetchFrameFunction, - FrameActionBodyPayload, - FrameContext, FrameGETRequest, FramePOSTRequest, FrameStackPending, FrameStackPostPending, - GetFrameResult, SignedFrameAction, SignerStateActionContext, SignerStateDefaultActionContext, UseFetchFrameOptions, UseFetchFrameSignFrameActionFunction, } from "./types"; -import { isParseResult } from "./use-frame-stack"; import { SignatureHandlerDidNotReturnTransactionIdError, TransactionDataErrorResponseError, @@ -39,7 +30,11 @@ import { CastActionUnexpectedResponseError, ComposerActionUnexpectedResponseError, } from "./errors"; -import { tryCall, tryCallAsync } from "./helpers"; +import { + isParseFramesWithReportsResult, + tryCall, + tryCallAsync, +} from "./helpers"; function isErrorMessageResponse( response: unknown @@ -89,14 +84,8 @@ function defaultErrorHandler(error: Error): void { console.error(error); } -export function useFetchFrame< - TSignerStorageType = Record, - TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, - TFrameContextType extends FrameContext = FarcasterFrameContext, ->({ +export function useFetchFrame({ stackAPI, - stackDispatch, - specification, frameActionProxy, frameGetProxy, extraButtonRequestPayload, @@ -120,13 +109,7 @@ export function useFetchFrame< onTransactionProcessingError, onTransactionProcessingStart, onTransactionProcessingSuccess, -}: UseFetchFrameOptions< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType ->): FetchFrameFunction< - SignerStateActionContext -> { +}: UseFetchFrameOptions): FetchFrameFunction { async function handleFailedResponse({ response, endTime, @@ -193,7 +176,7 @@ export function useFetchFrame< if (shouldClear) { // this clears initial frame since that is loading from SSR since we aren't able to finish it. // not an ideal solution - stackDispatch({ action: "CLEAR" }); + stackAPI.clear(); } const frameStackPendingItem = stackAPI.createGetPendingItem({ request }); @@ -201,7 +184,6 @@ export function useFetchFrame< const response = await fetchProxied({ proxyUrl: frameGetProxy, fetchFn, - specification, url: request.url, }); @@ -218,12 +200,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({ + 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."); + + stackAPI.markAsFailed({ + pendingItem: frameStackPendingItem, + endTime, + requestError: error, + response, + responseBody: "none", + responseStatus: 500, + }); + + tryCall(() => { + onError(error); + }); + + return; + } stackAPI.markAsDone({ pendingItem: frameStackPendingItem, endTime, - frameResult, + parseResult, response, }); @@ -244,9 +264,7 @@ export function useFetchFrame< } async function fetchPOSTRequest( - request: FramePOSTRequest< - SignerStateActionContext - >, + request: FramePOSTRequest, options?: { preflightRequest?: { pendingFrameStackItem: FrameStackPostPending; @@ -260,7 +278,7 @@ export function useFetchFrame< let pendingItem: FrameStackPostPending; if (options?.shouldClear) { - stackDispatch({ action: "CLEAR" }); + stackAPI.clear(); } // get rid of address from request.signerStateActionContext.frameContext and pass that to sign frame action @@ -302,7 +320,6 @@ export function useFetchFrame< const response = await fetchProxied({ proxyUrl: frameActionProxy, - specification, fetchFn, frameAction: signedDataOrError, extraRequestPayload: extraButtonRequestPayload, @@ -440,7 +457,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 +478,7 @@ export function useFetchFrame< stackAPI.markAsDone({ endTime, - frameResult: responseData, + parseResult: responseData, pendingItem, response, }); @@ -489,9 +506,7 @@ export function useFetchFrame< } async function fetchTransactionRequest( - request: FramePOSTRequest< - SignerStateActionContext - >, + request: FramePOSTRequest, shouldClear?: boolean ): Promise { if ("source" in request) { @@ -514,7 +529,7 @@ export function useFetchFrame< } if (shouldClear) { - stackDispatch({ action: "CLEAR" }); + stackAPI.clear(); } tryCall(() => onTransactionDataStart?.({ button })); @@ -537,17 +552,11 @@ export function useFetchFrame< return; } - signedTransactionDataActionOrError.searchParams.set( - "specification", - specification - ); - const transactionDataStartTime = new Date(); const transactionDataResponse = await fetchProxied({ proxyUrl: frameActionProxy, frameAction: signedTransactionDataActionOrError, fetchFn, - specification, extraRequestPayload: extraButtonRequestPayload, }); const transactionDataEndTime = new Date(); @@ -774,9 +783,7 @@ export function useFetchFrame< } async function fetchCastActionRequest( - request: CastActionRequest< - SignerStateActionContext - >, + request: CastActionRequest, shouldClear = false ): Promise { const frameButton: FrameButtonPost = { @@ -784,19 +791,12 @@ export function useFetchFrame< label: request.action.name, target: request.action.action.postUrl || request.action.url, }; - const signerStateActionContext: SignerStateDefaultActionContext< - TSignerStorageType, - TFrameContextType - > = { + const signerStateActionContext: SignerStateDefaultActionContext = { ...request.signerStateActionContext, type: "default", frameButton, }; - const signedDataOrError = await signAndGetFrameActionBodyPayload< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType - >({ + const signedDataOrError = await signAndGetFrameActionBodyPayload({ signerStateActionContext, signFrameAction, }); @@ -828,7 +828,6 @@ export function useFetchFrame< const actionResponseOrError = await fetchProxied({ fetchFn, proxyUrl: frameActionProxy, - specification, frameAction: signedDataOrError, extraRequestPayload: extraButtonRequestPayload, }); @@ -916,9 +915,7 @@ export function useFetchFrame< } async function fetchComposerActionRequest( - request: ComposerActionRequest< - SignerStateActionContext - >, + request: ComposerActionRequest, shouldClear = false ): Promise { const frameButton: FrameButtonPost = { @@ -926,10 +923,7 @@ export function useFetchFrame< label: request.action.name, target: request.action.url, }; - const signerStateActionContext: SignerStateDefaultActionContext< - TSignerStorageType, - TFrameContextType - > = { + const signerStateActionContext: SignerStateDefaultActionContext = { ...request.signerStateActionContext, type: "default", frameButton, @@ -939,11 +933,7 @@ export function useFetchFrame< } satisfies ComposerActionStateFromMessage) ), }; - const signedDataOrError = await signAndGetFrameActionBodyPayload< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType - >({ + const signedDataOrError = await signAndGetFrameActionBodyPayload({ signerStateActionContext, signFrameAction, }); @@ -975,7 +965,6 @@ export function useFetchFrame< const actionResponseOrError = await fetchProxied({ fetchFn, proxyUrl: frameActionProxy, - specification, frameAction: signedDataOrError, extraRequestPayload: extraButtonRequestPayload, }); @@ -1079,7 +1068,6 @@ function proxyUrlAndSearchParamsToUrl( type FetchProxiedArg = { proxyUrl: string; - specification: SupportedParsingSpecification; fetchFn: typeof fetch; } & ( | { @@ -1092,9 +1080,7 @@ type FetchProxiedArg = { async function fetchProxied( params: FetchProxiedArg ): Promise { - const searchParams = new URLSearchParams({ - specification: params.specification, - }); + const searchParams = new URLSearchParams(); if ("frameAction" in params) { const proxyUrl = proxyUrlAndSearchParamsToUrl( @@ -1132,45 +1118,27 @@ function getResponseBody(response: Response): Promise { return response.clone().text(); } -type SignAndGetFrameActionPayloadOptions< - TSignerStorageType, - TFrameActionBodyType extends FrameActionBodyPayload, - TFrameContextType extends FrameContext, -> = { - signerStateActionContext: SignerStateActionContext< - TSignerStorageType, - TFrameContextType - >; - signFrameAction: UseFetchFrameSignFrameActionFunction< - SignerStateActionContext, - TFrameActionBodyType - >; +type SignAndGetFrameActionPayloadOptions = { + signerStateActionContext: SignerStateActionContext; + signFrameAction: UseFetchFrameSignFrameActionFunction; }; /** * This shouldn't be used for transaction data request */ -async function signAndGetFrameActionBodyPayload< - TSignerStorageType, - TFrameActionBodyType extends FrameActionBodyPayload, - TFrameContextType extends FrameContext, ->({ +async function signAndGetFrameActionBodyPayload({ signerStateActionContext, signFrameAction, -}: SignAndGetFrameActionPayloadOptions< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType ->): Promise { +}: SignAndGetFrameActionPayloadOptions): Promise { // Transacting address is not included in post action const { address: _, ...requiredFrameContext } = - signerStateActionContext.frameContext as unknown as FarcasterFrameContext; + signerStateActionContext.frameContext; return tryCallAsync(() => signFrameAction({ actionContext: { ...signerStateActionContext, - frameContext: requiredFrameContext as unknown as TFrameContextType, + frameContext: requiredFrameContext, }, }) ); diff --git a/packages/render/src/use-frame-stack.ts b/packages/render/src/use-frame-stack.ts index 5c5ac0fc0..a697189e2 100644 --- a/packages/render/src/use-frame-stack.ts +++ b/packages/render/src/use-frame-stack.ts @@ -1,6 +1,9 @@ import { useMemo, useReducer } from "react"; import type { Frame } from "frames.js"; -import type { ParseResult } from "frames.js/frame-parsers"; +import type { + ParseFramesWithReportsResult, + ParseResult, +} from "frames.js/frame-parsers"; import type { CastActionMessageResponse, ErrorMessageResponse, @@ -12,19 +15,17 @@ import type { FramesStack, FrameStackGetPending, FrameStackPostPending, - GetFrameResult, SignedFrameAction, - SignerStateActionContext, } from "./types"; +import { + createParseFramesWithReportsObject, + isParseFramesWithReportsResult, +} 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 @@ -64,49 +65,26 @@ function framesStackReducer( return state.slice(); } case "RESET_INITIAL_FRAME": { - const originalInitialFrame = state[0]; - const frame = isParseResult(action.resultOrFrame) - ? action.resultOrFrame.frame - : action.resultOrFrame; - // initial frame is always set with done state - const shouldReset = - !originalInitialFrame || - (originalInitialFrame.status === "done" && - originalInitialFrame.frameResult.frame !== frame); - - if (shouldReset) { - const frameResult = isParseResult(action.resultOrFrame) - ? action.resultOrFrame - : { - status: "success" as const, - reports: {}, - frame: action.resultOrFrame, - }; - - return [ - { - 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", - // @todo should this be result or frame? - responseBody: frameResult, + return [ + { + request: { + method: "GET", + url: action.homeframeUrl, }, - ]; - } - - return state; + url: action.homeframeUrl, + requestDetails: {}, + response: new Response(JSON.stringify(action.parseResult), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + responseStatus: 200, + timestamp: new Date(), + speed: 0, + parseResult: action.parseResult, + status: "done", + responseBody: action.parseResult, + }, + ]; } case "CLEAR": return []; @@ -116,20 +94,23 @@ function framesStackReducer( } type UseFrameStackOptions = { - initialFrame?: Frame | ParseResult; + /** + * Initial frame to be used, if the value isn't of ParseFramesWithReportsResult type then it will be converted to it + * using initialSpecification. + */ + initialFrame?: Frame | ParseResult | ParseFramesWithReportsResult; initialFrameUrl?: string | null; }; export type FrameStackAPI = { + dispatch: React.Dispatch; clear: () => void; createGetPendingItem: (arg: { request: FrameGETRequest; }) => FrameStackGetPending; - createPostPendingItem: < - TSignerStateActionContext extends SignerStateActionContext, - >(arg: { + createPostPendingItem: (arg: { action: SignedFrameAction; - request: FramePOSTRequest; + request: FramePOSTRequest; /** * Optional, allows to override the start time * @@ -140,11 +121,9 @@ export type FrameStackAPI = { /** * Creates a pending item without dispatching it */ - createCastOrComposerActionPendingItem: < - TSignerStateActionContext extends SignerStateActionContext, - >(arg: { + createCastOrComposerActionPendingItem: (arg: { action: SignedFrameAction; - request: FramePOSTRequest; + request: FramePOSTRequest; }) => FrameStackPostPending; markCastMessageAsDone: (arg: { pendingItem: FrameStackPostPending; @@ -164,7 +143,7 @@ export type FrameStackAPI = { pendingItem: FrameStackGetPending | FrameStackPostPending; endTime: Date; response: Response; - frameResult: GetFrameResult; + parseResult: ParseFramesWithReportsResult; }) => void; markAsDoneWithRedirect: (arg: { pendingItem: FrameStackPostPending; @@ -194,36 +173,32 @@ export type FrameStackAPI = { response: Response; responseBody: unknown; }) => void; + reset: (arg: { + frame: Frame | ParseResult | ParseFramesWithReportsResult; + homeframeUrl: string; + }) => void; }; export function useFrameStack({ initialFrame, initialFrameUrl, -}: UseFrameStackOptions): [ - FramesStack, - React.Dispatch, - FrameStackAPI, -] { +}: UseFrameStackOptions): [FramesStack, FrameStackAPI] { const [stack, dispatch] = useReducer( framesStackReducer, [initialFrame, initialFrameUrl] as const, ([frame, frameUrl]): FramesStack => { if (frame) { - const frameResult = isParseResult(frame) + const parseResult = isParseFramesWithReportsResult(frame) ? frame - : { - reports: {}, - frame, - status: "success" as const, - }; + : createParseFramesWithReportsObject(frame); return [ { - response: new Response(JSON.stringify(frameResult), { + response: new Response(JSON.stringify(parseResult), { status: 200, headers: { "Content-Type": "application/json" }, }), responseStatus: 200, - responseBody: frameResult, + responseBody: parseResult, timestamp: new Date(), requestDetails: {}, request: { @@ -231,7 +206,7 @@ export function useFrameStack({ url: frameUrl ?? "", }, speed: 0, - frameResult, + parseResult, status: "done", url: frameUrl ?? "", }, @@ -260,6 +235,7 @@ export function useFrameStack({ const api: FrameStackAPI = useMemo(() => { return { + dispatch, clear() { dispatch({ action: "CLEAR", @@ -351,14 +327,14 @@ export function useFrameStack({ item: { ...arg.pendingItem, status: "done", - frameResult: arg.frameResult, + parseResult: arg.parseResult, speed: computeDurationInSeconds( arg.pendingItem.timestamp, arg.endTime ), response: arg.response.clone(), responseStatus: arg.response.status, - responseBody: arg.frameResult, + responseBody: arg.parseResult, }, }); }, @@ -438,8 +414,17 @@ export function useFrameStack({ }, }); }, + reset(arg) { + dispatch({ + action: "RESET_INITIAL_FRAME", + homeframeUrl: arg.homeframeUrl, + parseResult: isParseFramesWithReportsResult(arg.frame) + ? arg.frame + : createParseFramesWithReportsObject(arg.frame), + }); + }, }; }, [dispatch]); - return [stack, dispatch, api]; + return [stack, api]; } diff --git a/packages/render/src/use-frame.tsx b/packages/render/src/use-frame.tsx index 02ae58151..fd4af83f0 100644 --- a/packages/render/src/use-frame.tsx +++ b/packages/render/src/use-frame.tsx @@ -1,13 +1,13 @@ /* 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, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Frame, - FrameButton, FrameButtonLink, FrameButtonPost, FrameButtonTx, + SupportedParsingSpecification, TransactionTargetResponse, } from "frames.js"; import type { @@ -20,10 +20,16 @@ import type { OnSignatureArgs, CastActionButtonPressFunction, ComposerActionButtonPressFunction, + ResolveFrameActionContextResult, + ButtonPressFunction, + AllowedStorageTypes, + SignerStateInstance, } from "./types"; -import { unsignedFrameAction, type FarcasterFrameContext } from "./farcaster"; +import { unsignedFrameAction } from "./farcaster"; import { useFrameStack } from "./use-frame-stack"; import { useFetchFrame } from "./use-fetch-frame"; +import { useFreshRef } from "./hooks/use-fresh-ref"; +import type { FarcasterFrameContext } from "./farcaster/types"; function onMintFallback({ target }: OnMintArgs): void { console.log("Please provide your own onMint function to useFrame() hook."); @@ -144,14 +150,26 @@ function validateLinkButtonTarget(target: string): boolean { return true; } +function sanitizeSpecification( + specification: SupportedParsingSpecification | SupportedParsingSpecification[] +): SupportedParsingSpecification[] { + const value = Array.isArray(specification) ? specification : [specification]; + + if (value.length === 0) { + return ["farcaster"]; + } + + return value; +} + export function useFrame< - TSignerStorageType = Record, + TSignerStorageType extends AllowedStorageTypes = Record, TFrameActionBodyType extends FrameActionBodyPayload = FrameActionBodyPayload, TFrameContextType extends FrameContext = FarcasterFrameContext, >({ homeframeUrl, frameContext, - dangerousSkipSigning, + dangerousSkipSigning = false, onMint = onMintFallback, onTransaction = onTransactionFallback, transactionDataSuffix, @@ -166,6 +184,7 @@ export function useFrame< frameGetProxy, extraButtonRequestPayload, specification = "farcaster", + resolveActionContext, onError, onLinkButtonClick = handleLinkButtonClickFallback, onRedirect = handleRedirectFallback, @@ -184,35 +203,50 @@ export function useFrame< TSignerStorageType, TFrameActionBodyType, TFrameContextType ->): FrameState { +>): FrameState { const [inputText, setInputText] = useState(""); - const [framesStack, dispatch, stackAPI] = useFrameStack({ + const inputTextRef = useFreshRef(inputText); + const [framesStack, stackAPI] = useFrameStack({ initialFrame: frame, initialFrameUrl: homeframeUrl, }); + const [specifications, setSpecifications] = useState(() => + sanitizeSpecification(specification) + ); + const specificationsRef = useFreshRef(specifications); + const resolveActionContextRef = useFreshRef(resolveActionContext); + const onErrorRef = useFreshRef(onError); + const signerStateRef = useFreshRef(signerState); + const frameContextRef = useFreshRef(frameContext); + const dangerousSkipSigningRef = useFreshRef(dangerousSkipSigning); + const onConnectWalletRef = useFreshRef(onConnectWallet); + const onMintRef = useFreshRef(onMint); + const onLinkButtonClickRef = useFreshRef(onLinkButtonClick); + const connectedAddressRef = useFreshRef(connectedAddress); + + const { + clear: clearFrameStack, + dispatch, + reset: resetToInitialFrame, + } = stackAPI; - const fetchFrame = useFetchFrame< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType - >({ + const frameStackRef = useFreshRef(framesStack); + const fetchFrame = useFetchFrame({ stackAPI, frameActionProxy, frameGetProxy, onTransaction, transactionDataSuffix, onSignature, - signFrameAction({ actionContext, forceRealSigner }) { - return dangerousSkipSigning && !forceRealSigner - ? unsignedFrameAction< - TSignerStorageType, - TFrameActionBodyType, - TFrameContextType - >(actionContext) - : signerState.signFrameAction(actionContext); + async signFrameAction({ actionContext, forceRealSigner }) { + if (dangerousSkipSigning && !forceRealSigner) { + return unsignedFrameAction(actionContext); + } + + const resolvedActionContext = await resolveInternalActionContext(); + + return resolvedActionContext.signerState.signFrameAction(actionContext); }, - specification, - stackDispatch: dispatch, extraButtonRequestPayload, onError, fetchFn, @@ -228,35 +262,32 @@ export function useFrame< onTransactionStart, onTransactionSuccess, }); + const fetchFrameRef = useFreshRef(fetchFrame); + + const resolveInternalActionContext = + useCallback(async (): Promise => { + if (!resolveActionContextRef.current) { + return { + frameContext: frameContextRef.current, + // it is a signer state just generic params don't match, but the expected type is more + // forgiving so we can just use defaults + signerState: signerStateRef.current as unknown as SignerStateInstance, + }; + } - const fetchFrameRef = useRef(fetchFrame); - fetchFrameRef.current = fetchFrame; - const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - useEffect(() => { - if (!frame && homeframeUrl) { - 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 if (frame) { - dispatch({ - action: "RESET_INITIAL_FRAME", - resultOrFrame: frame, - homeframeUrl, + return resolveActionContextRef.current({ + dangerouslySkipSigning: dangerousSkipSigningRef.current, + frameStack: frameStackRef.current, + specifications: specificationsRef.current, }); - } - }, [frame, homeframeUrl, dispatch]); + }, [ + dangerousSkipSigningRef, + frameContextRef, + frameStackRef, + resolveActionContextRef, + signerStateRef, + specificationsRef, + ]); const onPostButton = useCallback( async function onPostButton({ @@ -276,30 +307,37 @@ export function useFrame< target: string; fetchFrameOverride?: typeof fetchFrame; }): Promise { - if (!dangerousSkipSigning && !signerState.hasSigner) { - const error = new Error("Missing signer"); + // normally this shouldn't happen because not having homeframeUrl will prevent ui from being rendered + if (!homeframeUrl) { + const error = new Error("Missing homeframeUrl"); onErrorRef.current?.(error); - console.error(`@frames.js/render: ${error.message}`); return; } - if (!homeframeUrl) { - const error = new Error("Missing homeframeUrl"); + + const resolvedActionContext = await resolveInternalActionContext(); + + if ( + !dangerousSkipSigningRef.current && + !resolvedActionContext.signerState.hasSigner + ) { + const error = new Error("Missing signer"); onErrorRef.current?.(error); + console.error(`@frames.js/render: ${error.message}`); return; } - const _fetchFrame = fetchFrameOverride ?? fetchFrame; + const _fetchFrame = fetchFrameOverride ?? fetchFrameRef.current; await _fetchFrame({ frameButton, - isDangerousSkipSigning: dangerousSkipSigning ?? false, + isDangerousSkipSigning: dangerousSkipSigningRef.current, method: "POST", signerStateActionContext: { inputText: postInputText, - signer: signerState.signer ?? null, - frameContext, + signer: resolvedActionContext.signerState.signer ?? null, + frameContext: resolvedActionContext.frameContext, url: homeframeUrl, target, frameButton, @@ -310,18 +348,14 @@ export function useFrame< }); }, [ - dangerousSkipSigning, - fetchFrame, - frameContext, + dangerousSkipSigningRef, + fetchFrameRef, homeframeUrl, - signerState.hasSigner, - signerState.signer, + onErrorRef, + resolveInternalActionContext, ] ); - const onConnectWalletRef = useRef(onConnectWallet); - onConnectWalletRef.current = onConnectWallet; - const onTransactionButton = useCallback( async function onTransactionButton({ currentFrame, @@ -334,22 +368,30 @@ export function useFrame< buttonIndex: number; postInputText: string | undefined; }): Promise { - // Send post request to get calldata - if (!dangerousSkipSigning && !signerState.hasSigner) { - const error = new Error("Missing signer"); + // normally this shouldn't happen because not having homeframeUrl will prevent ui from being rendered + if (!homeframeUrl) { + const error = new Error("Missing homeframeUrl"); onErrorRef.current?.(error); - console.error(`@frames.js/render: ${error.message}`); return; } - if (!homeframeUrl) { - const error = new Error("Missing homeframeUrl"); + + const resolvedActionContext = await resolveInternalActionContext(); + const currentConnectedAddress = connectedAddressRef.current; + + // Send post request to get calldata + if ( + !dangerousSkipSigningRef.current && + !resolvedActionContext.signerState.hasSigner + ) { + const error = new Error("Missing signer"); onErrorRef.current?.(error); + console.error(`@frames.js/render: ${error.message}`); return; } - if (!connectedAddress) { + if (!currentConnectedAddress) { try { onConnectWalletRef.current(); } catch (e) { @@ -360,16 +402,16 @@ export function useFrame< return; } - await fetchFrame({ + await fetchFrameRef.current({ frameButton, - isDangerousSkipSigning: dangerousSkipSigning ?? false, + isDangerousSkipSigning: dangerousSkipSigningRef.current, method: "POST", signerStateActionContext: { type: "tx-data", inputText: postInputText, - signer: signerState.signer ?? null, - frameContext, - address: connectedAddress, + signer: resolvedActionContext.signerState.signer ?? null, + frameContext: resolvedActionContext.frameContext, + address: currentConnectedAddress, url: homeframeUrl, target: frameButton.target, frameButton, @@ -380,38 +422,23 @@ export function useFrame< }); }, [ - fetchFrame, - dangerousSkipSigning, - frameContext, - connectedAddress, homeframeUrl, - signerState, + resolveInternalActionContext, + connectedAddressRef, + dangerousSkipSigningRef, + fetchFrameRef, + onErrorRef, + onConnectWalletRef, ] ); - const onButtonPress = useCallback( + const onButtonPress: ButtonPressFunction = useCallback( async function onButtonPress( - currentFrame: Frame, - frameButton: FrameButton, - index: number, - fetchFrameOverride: typeof fetchFrame = fetchFrame + currentFrame, + frameButton, + index, + fetchFrameOverride ): Promise { - // Button actions that are handled without server interaction don't require signer - const clientSideActions = ["mint", "link"]; - const buttonRequiresAuth = !clientSideActions.includes( - frameButton.action - ); - - if ( - !signerState.hasSigner && - !dangerousSkipSigning && - buttonRequiresAuth - ) { - await signerState.onSignerlessFramePress(); - // don't continue, let the app handle - return; - } - switch (frameButton.action) { case "link": { try { @@ -423,11 +450,11 @@ export function useFrame< return; } - onLinkButtonClick(frameButton); + onLinkButtonClickRef.current(frameButton); break; } case "mint": { - onMint({ + onMintRef.current({ frameButton, target: frameButton.target, frame: currentFrame, @@ -439,7 +466,9 @@ export function useFrame< frameButton, buttonIndex: index + 1, postInputText: - currentFrame.inputText !== undefined ? inputText : undefined, + currentFrame.inputText !== undefined + ? inputTextRef.current + : undefined, currentFrame, }); break; @@ -479,7 +508,9 @@ export function useFrame< target, buttonIndex: index + 1, postInputText: - currentFrame.inputText !== undefined ? inputText : undefined, + currentFrame.inputText !== undefined + ? inputTextRef.current + : undefined, state: currentFrame.state, fetchFrameOverride, }); @@ -498,38 +529,37 @@ export function useFrame< } }, [ - dangerousSkipSigning, - fetchFrame, homeframeUrl, - inputText, - onLinkButtonClick, - onMint, + inputTextRef, + onErrorRef, + onLinkButtonClickRef, + onMintRef, onPostButton, onTransactionButton, - signerState, ] ); - const clearFrameStack = useCallback(() => { - dispatch({ action: "CLEAR" }); - }, [dispatch]); - const onCastActionButtonPress: CastActionButtonPressFunction = useCallback( async function onActionButtonPress(arg) { - if (!signerState.hasSigner && !dangerousSkipSigning) { - await signerState.onSignerlessFramePress(); + const resolvedActionContext = await resolveInternalActionContext(); + + if ( + !resolvedActionContext.signerState.hasSigner && + !dangerousSkipSigningRef.current + ) { + await resolvedActionContext.signerState.onSignerlessFramePress(); // don't continue, let the app handle return; } - return fetchFrame( + return fetchFrameRef.current( { method: "CAST_ACTION", action: arg.castAction, - isDangerousSkipSigning: dangerousSkipSigning ?? false, + isDangerousSkipSigning: dangerousSkipSigningRef.current, signerStateActionContext: { - signer: signerState.signer ?? null, - frameContext, + signer: resolvedActionContext.signerState.signer ?? null, + frameContext: resolvedActionContext.frameContext, url: arg.castAction.url, target: arg.castAction.url, buttonIndex: 1, @@ -538,27 +568,32 @@ export function useFrame< arg.clearStack ); }, - [dangerousSkipSigning, fetchFrame, frameContext, signerState] + [dangerousSkipSigningRef, fetchFrameRef, resolveInternalActionContext] ); const onComposerActionButtonPress: ComposerActionButtonPressFunction = useCallback( async function onActionButtonPress(arg) { - if (!signerState.hasSigner && !dangerousSkipSigning) { - await signerState.onSignerlessFramePress(); + const resolvedActionContext = await resolveInternalActionContext(); + + if ( + !resolvedActionContext.signerState.hasSigner && + !dangerousSkipSigningRef.current + ) { + await resolvedActionContext.signerState.onSignerlessFramePress(); // don't continue, let the app handle return; } - return fetchFrame( + return fetchFrameRef.current( { method: "COMPOSER_ACTION", action: arg.castAction, - isDangerousSkipSigning: dangerousSkipSigning ?? false, + isDangerousSkipSigning: dangerousSkipSigningRef.current, composerActionState: arg.composerActionState, signerStateActionContext: { - signer: signerState.signer ?? null, - frameContext, + signer: resolvedActionContext.signerState.signer ?? null, + frameContext: resolvedActionContext.frameContext, url: arg.castAction.url, target: arg.castAction.url, buttonIndex: 1, @@ -567,9 +602,56 @@ export function useFrame< arg.clearStack ); }, - [dangerousSkipSigning, fetchFrame, frameContext, signerState] + [dangerousSkipSigningRef, fetchFrameRef, resolveInternalActionContext] ); + // update specifications only if they changed + // this makes sure that even if the user is passing always a new array it will not trigger a re-render + useEffect(() => { + const newSpecifications = sanitizeSpecification(specification); + + if (JSON.stringify(newSpecifications) !== JSON.stringify(specifications)) { + setSpecifications(newSpecifications); + } + }, [specification, specifications]); + + useEffect(() => { + // if there is no homeframeUrl then we treat the useFrame as "disabled" + // meaning it will not fetch anything nor show anything + if (!homeframeUrl) { + // thre is no frame url or user removed the url + clearFrameStack(); + 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 { + resetToInitialFrame({ + frame, + homeframeUrl, + }); + } + }, [ + frame, + homeframeUrl, + resetToInitialFrame, + clearFrameStack, + fetchFrameRef, + ]); + return useMemo(() => { return { inputText, @@ -583,6 +665,7 @@ export function useFrame< currentFrameStackItem: framesStack[0], onCastActionButtonPress, onComposerActionButtonPress, + specifications, }; }, [ inputText, @@ -594,5 +677,6 @@ export function useFrame< framesStack, onCastActionButtonPress, onComposerActionButtonPress, + specifications, ]); } From 5e58f9366301a373b2ac711c205bd1b5a7ee4c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Tue, 22 Oct 2024 15:33:50 +0200 Subject: [PATCH 03/11] feat: debugger use new multiprotocol support --- .../debugger/app/components/cast-composer.tsx | 13 +- .../app/components/frame-debugger.tsx | 81 +++++++----- packages/debugger/app/debugger-page.tsx | 118 ++++++++++++------ packages/debugger/app/frames/route.ts | 37 +++--- 4 files changed, 160 insertions(+), 89 deletions(-) diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 8452fa809..15b0d4fbe 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -23,6 +23,7 @@ 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 { getFrameParseResultFromStackItemBySpecifications } from "@frames.js/render/helpers"; type CastComposerProps = { composerAction: Partial; @@ -139,11 +140,15 @@ function createDebugUrl(frameUrl: string, currentUrl: string) { } function isAtLeastPartialFrame(stackItem: FrameStackDone): boolean { + const result = getFrameParseResultFromStackItemBySpecifications(stackItem, [ + "farcaster", + ]); + return ( - stackItem.frameResult.status === "success" || - (!!stackItem.frameResult.frame && - !!stackItem.frameResult.frame.buttons && - stackItem.frameResult.frame.buttons.length > 0) + result.status === "success" || + (!!result.frame && + !!result.frame.buttons && + result.frame.buttons.length > 0) ); } diff --git a/packages/debugger/app/components/frame-debugger.tsx b/packages/debugger/app/components/frame-debugger.tsx index dc0018f66..528f8835b 100644 --- a/packages/debugger/app/components/frame-debugger.tsx +++ b/packages/debugger/app/components/frame-debugger.tsx @@ -22,6 +22,7 @@ import { CollapsedFrameUI, defaultTheme, } from "@frames.js/render"; +import { getFrameParseResultFromStackItemBySpecifications } from "@frames.js/render/helpers"; import { FrameImageNext } from "@frames.js/render/next"; import { Table, TableBody, TableCell, TableRow } from "@/components/table"; import { @@ -59,13 +60,10 @@ import { urlSearchParamsToObject } from "../utils/url-search-params-to-object"; import { FrameUI } from "./frame-ui"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@/components/ui/toast"; -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"; type FrameDiagnosticsProps = { stackItem: FramesStackItem; + specification: SupportedParsingSpecification; }; function isPropertyExperimental([key, value]: [string, string]) { @@ -73,7 +71,7 @@ function isPropertyExperimental([key, value]: [string, string]) { return false; } -function FrameDiagnostics({ stackItem }: FrameDiagnosticsProps) { +function FrameDiagnostics({ stackItem, specification }: FrameDiagnosticsProps) { const properties = useMemo(() => { /** tuple of key and value */ const validProperties: [string, string][] = []; @@ -97,7 +95,9 @@ function FrameDiagnostics({ stackItem }: FrameDiagnosticsProps) { return { validProperties, invalidProperties, isValid: true }; } - const result = stackItem.frameResult; + const result = getFrameParseResultFromStackItemBySpecifications(stackItem, [ + specification, + ]); // we need to check validation errors first because getFrame incorrectly return a value for a key even if it's invalid for (const [key, reports] of Object.entries(result.reports)) { @@ -160,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]) => { @@ -248,7 +248,8 @@ function ShortenedText({ const FramesRequestCardContentIcon: React.FC<{ stackItem: FramesStackItem; -}> = ({ stackItem }) => { + specification: SupportedParsingSpecification; +}> = ({ stackItem, specification }) => { if (stackItem.status === "pending") { return ; } @@ -269,11 +270,15 @@ const FramesRequestCardContentIcon: React.FC<{ return ; } - if (stackItem.frameResult?.status === "failure") { + const result = getFrameParseResultFromStackItemBySpecifications(stackItem, [ + specification, + ]); + + if (result.status === "failure") { return ; } - if (hasWarnings(stackItem.frameResult.reports)) { + if (hasWarnings(result.reports)) { return ; } @@ -282,10 +287,9 @@ const FramesRequestCardContentIcon: React.FC<{ const FramesRequestCardContent: React.FC<{ stack: FramesStack; - fetchFrame: FrameState< - FarcasterSigner | XmtpSigner | LensSigner | AnonymousSigner | null - >["fetchFrame"]; -}> = ({ fetchFrame, stack }) => { + fetchFrame: FrameState["fetchFrame"]; + specification: SupportedParsingSpecification; +}> = ({ fetchFrame, specification, stack }) => { return stack.map((frameStackItem, i) => { return ( diff --git a/packages/render/src/frame-ui.tsx b/packages/render/src/frame-ui.tsx index 916afac4d..9fc9a3045 100644 --- a/packages/render/src/frame-ui.tsx +++ b/packages/render/src/frame-ui.tsx @@ -1,6 +1,14 @@ import type { ImgHTMLAttributes } from "react"; import React, { useState } from "react"; -import type { Frame, FrameButton } from "frames.js"; +import type { + Frame, + FrameButton, + SupportedParsingSpecification, +} from "frames.js"; +import type { + ParseFramesWithReportsResult, + ParseResult, +} from "frames.js/frame-parsers"; import type { FrameTheme, FrameState } from "./types"; import { getErrorMessageFromFramesStackItem, @@ -146,28 +154,35 @@ export function FrameUI({ } let frame: Frame | Partial | undefined; + let parseResult: ParseFramesWithReportsResult | undefined; + let specification: SupportedParsingSpecification | undefined; let debugImage: string | undefined; if (currentFrameStackItem.status === "done") { - const parseResult = getFrameParseResultFromStackItemBySpecifications( + const parseResultBySpec = getFrameParseResultFromStackItemBySpecifications( currentFrameStackItem, specifications ); - frame = parseResult.frame; + frame = parseResultBySpec.frame; + parseResult = currentFrameStackItem.parseResult; + specification = parseResultBySpec.specification; debugImage = enableImageDebugging - ? parseResult.framesDebugInfo?.image + ? parseResultBySpec.framesDebugInfo?.image : undefined; } else if ( currentFrameStackItem.status === "message" || currentFrameStackItem.status === "doneRedirect" ) { frame = currentFrameStackItem.request.sourceFrame; + parseResult = currentFrameStackItem.request.sourceParseResult; + specification = currentFrameStackItem.request.specification; } else if (currentFrameStackItem.status === "requestError") { - frame = - "sourceFrame" in currentFrameStackItem.request - ? currentFrameStackItem.request.sourceFrame - : undefined; + if ("sourceFrame" in currentFrameStackItem.request) { + frame = currentFrameStackItem.request.sourceFrame; + parseResult = currentFrameStackItem.request.sourceParseResult; + specification = currentFrameStackItem.request.specification; + } } const ImageEl = FrameImage ? FrameImage : "img"; @@ -233,7 +248,11 @@ export function FrameUI({ }} /> ) : null} - {!!frame && !!frame.buttons && frame.buttons.length > 0 ? ( + {!!parseResult && + !!specification && + !!frame && + !!frame.buttons && + frame.buttons.length > 0 ? (
{frame.buttons.map((frameButton: FrameButton, index: number) => (