diff --git a/.changeset/cuddly-rivers-hide.md b/.changeset/cuddly-rivers-hide.md new file mode 100644 index 000000000..1b34e3cfd --- /dev/null +++ b/.changeset/cuddly-rivers-hide.md @@ -0,0 +1,5 @@ +--- +"@frames.js/render": patch +--- + +feat: customizable frame state for useFrame hook diff --git a/.changeset/grumpy-berries-love.md b/.changeset/grumpy-berries-love.md new file mode 100644 index 000000000..0969e5b84 --- /dev/null +++ b/.changeset/grumpy-berries-love.md @@ -0,0 +1,6 @@ +--- +"@frames.js/debugger": patch +"@frames.js/render": patch +--- + +feat: useComposerAction hook diff --git a/packages/debugger/app/components/action-debugger.tsx b/packages/debugger/app/components/action-debugger.tsx index c6835584b..851358855 100644 --- a/packages/debugger/app/components/action-debugger.tsx +++ b/packages/debugger/app/components/action-debugger.tsx @@ -9,7 +9,6 @@ import { cn } from "@/lib/utils"; import { type FarcasterFrameContext, type FrameActionBodyPayload, - OnComposeFormActionFuncReturnType, defaultTheme, } from "@frames.js/render"; import { ParsingReport } from "frames.js"; @@ -26,7 +25,6 @@ import React, { useEffect, useImperativeHandle, useMemo, - useRef, useState, } from "react"; import { Button } from "../../@/components/ui/button"; @@ -37,12 +35,9 @@ import { useFrame } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; import { useToast } from "@/components/ui/use-toast"; import type { CastActionDefinitionResponse } from "../frames/route"; -import { ComposerFormActionDialog } from "./composer-form-action-dialog"; -import { AwaitableController } from "../lib/awaitable-controller"; -import type { ComposerActionFormResponse } from "frames.js/types"; -import { CastComposer, CastComposerRef } from "./cast-composer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { ComposerActionDebugger } from "./composer-action-debugger"; type FrameDebuggerFramePropertiesTableRowsProps = { actionMetadataItem: CastActionDefinitionResponse; @@ -227,47 +222,9 @@ export const ActionDebugger = React.forwardRef< } }, [copySuccess, setCopySuccess]); - const [composeFormActionDialogSignal, setComposerFormActionDialogSignal] = - useState | null>(null); const actionFrameState = useFrame({ ...farcasterFrameConfig, - async onComposerFormAction({ form }) { - try { - const dialogSignal = new AwaitableController< - OnComposeFormActionFuncReturnType, - ComposerActionFormResponse - >(form); - - setComposerFormActionDialogSignal(dialogSignal); - - const result = await dialogSignal; - - // if result is undefined then user closed the dialog window without submitting - // otherwise we have updated data - if (result?.composerActionState) { - castComposerRef.current?.updateState(result.composerActionState); - } - - return result; - } catch (e) { - console.error(e); - toast({ - title: "Error occurred", - description: - e instanceof Error - ? e.message - : "Unexpected error, check the console for more info", - variant: "destructive", - }); - } finally { - setComposerFormActionDialogSignal(null); - } - }, }); - const castComposerRef = useRef(null); const [castActionDefinition, setCastActionDefinition] = useState refreshUrl()} > - { - if (actionMetadataItem.status !== "success") { - console.error(actionMetadataItem); - - toast({ - title: "Invalid action metadata", - description: - "Please check the console for more information", - variant: "destructive", - }); - return; - } - - Promise.resolve( - actionFrameState.onComposerActionButtonPress({ - castAction: { - ...actionMetadataItem.action, - url: actionMetadataItem.url, - }, - composerActionState, - // clear stack, this removes first item that will appear in the debugger - clearStack: true, - }) - ).catch((e: unknown) => { - // eslint-disable-next-line no-console -- provide feedback to the user - console.error(e); - }); + { + setActiveTab("cast-action"); }} /> - - {!!composeFormActionDialogSignal && ( - { - composeFormActionDialogSignal.resolve(undefined); - }} - onSave={({ composerState }) => { - composeFormActionDialogSignal.resolve({ - composerActionState: composerState, - }); - }} - onTransaction={farcasterFrameConfig.onTransaction} - onSignature={farcasterFrameConfig.onSignature} - /> - )} diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index 8452fa809..957a48bae 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -11,29 +11,21 @@ import { ExternalLinkIcon, } from "lucide-react"; import IconByName from "./octicons"; -import { useFrame } from "@frames.js/render/use-frame"; +import { useFrame_unstable } from "@frames.js/render/use-frame"; import { WithTooltip } from "./with-tooltip"; -import type { - FarcasterFrameContext, - FrameActionBodyPayload, - FrameStackDone, -} from "@frames.js/render"; +import { fallbackFrameContext } from "@frames.js/render"; import { FrameUI } from "./frame-ui"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; -import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; +import { useAccount } from "wagmi"; +import { FrameStackDone } from "@frames.js/render/unstable-types"; +import { useDebuggerFrameState } from "../hooks/useDebuggerFrameState"; type CastComposerProps = { composerAction: Partial; onComposerActionClick: (state: ComposerActionState) => any; - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; }; export type CastComposerRef = { @@ -43,7 +35,7 @@ export type CastComposerRef = { export const CastComposer = React.forwardRef< CastComposerRef, CastComposerProps ->(({ composerAction, farcasterFrameConfig, onComposerActionClick }, ref) => { +>(({ composerAction, onComposerActionClick }, ref) => { const [state, setState] = useState({ text: "", embeds: [], @@ -79,7 +71,6 @@ export const CastComposer = React.forwardRef< {state.embeds.slice(0, 2).map((embed, index) => (
  • { const filteredEmbeds = state.embeds.filter( (_, i) => i !== index @@ -119,13 +110,6 @@ export const CastComposer = React.forwardRef< CastComposer.displayName = "CastComposer"; type CastEmbedPreviewProps = { - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; url: string; onRemove: () => void; }; @@ -147,15 +131,21 @@ function isAtLeastPartialFrame(stackItem: FrameStackDone): boolean { ); } -function CastEmbedPreview({ - farcasterFrameConfig, - onRemove, - url, -}: CastEmbedPreviewProps) { +function CastEmbedPreview({ onRemove, url }: CastEmbedPreviewProps) { + const account = useAccount(); const { toast } = useToast(); - const frame = useFrame({ - ...farcasterFrameConfig, + const farcasterIdentity = useFarcasterIdentity(); + const frame = useFrame_unstable({ + frameStateHook: useDebuggerFrameState, + async resolveAddress() { + return account.address ?? null; + }, homeframeUrl: url, + frameActionProxy: "/frames", + frameGetProxy: "/frames", + resolveSigner() { + return farcasterIdentity.withContext(fallbackFrameContext); + }, }); const handleFrameError = useCallback( diff --git a/packages/debugger/app/components/composer-action-debugger.tsx b/packages/debugger/app/components/composer-action-debugger.tsx new file mode 100644 index 000000000..faa620999 --- /dev/null +++ b/packages/debugger/app/components/composer-action-debugger.tsx @@ -0,0 +1,51 @@ +import type { + ComposerActionResponse, + ComposerActionState, +} from "frames.js/types"; +import { CastComposer, CastComposerRef } from "./cast-composer"; +import { useRef, useState } from "react"; +import { ComposerFormActionDialog } from "./composer-form-action-dialog"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; + +type ComposerActionDebuggerProps = { + url: string; + actionMetadata: Partial; + onToggleToCastActionDebugger: () => void; +}; + +export function ComposerActionDebugger({ + actionMetadata, + url, + onToggleToCastActionDebugger, +}: ComposerActionDebuggerProps) { + const castComposerRef = useRef(null); + const signer = useFarcasterIdentity(); + const [actionState, setActionState] = useState( + null + ); + + return ( + <> + + {!!actionState && ( + { + setActionState(null); + }} + onSubmit={(newActionState) => { + castComposerRef.current?.updateState(newActionState); + setActionState(null); + }} + onToggleToCastActionDebugger={onToggleToCastActionDebugger} + /> + )} + + ); +} diff --git a/packages/debugger/app/components/composer-form-action-dialog.tsx b/packages/debugger/app/components/composer-form-action-dialog.tsx index abea404db..68b18392b 100644 --- a/packages/debugger/app/components/composer-form-action-dialog.tsx +++ b/packages/debugger/app/components/composer-form-action-dialog.tsx @@ -5,221 +5,158 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { OnSignatureFunc, OnTransactionFunc } from "@frames.js/render"; -import type { - ComposerActionFormResponse, - ComposerActionState, -} from "frames.js/types"; -import { useCallback, useEffect, useRef } from "react"; -import { Abi, TypedDataDomain } from "viem"; -import { z } from "zod"; - -const createCastRequestSchemaLegacy = z.object({ - type: z.literal("createCast"), - data: z.object({ - cast: z.object({ - parent: z.string().optional(), - text: z.string(), - embeds: z.array(z.string().min(1).url()).min(1), - }), - }), -}); - -const ethSendTransactionActionSchema = z.object({ - chainId: z.string(), - method: z.literal("eth_sendTransaction"), - attribution: z.boolean().optional(), - params: z.object({ - abi: z.custom(), - to: z.custom<`0x${string}`>( - (val): val is `0x${string}` => - typeof val === "string" && val.startsWith("0x") - ), - value: z.string().optional(), - data: z - .custom<`0x${string}`>( - (val): val is `0x${string}` => - typeof val === "string" && val.startsWith("0x") - ) - .optional(), - }), -}); - -const ethSignTypedDataV4ActionSchema = z.object({ - chainId: z.string(), - method: z.literal("eth_signTypedData_v4"), - params: z.object({ - domain: z.custom(), - types: z.unknown(), - primaryType: z.string(), - message: z.record(z.unknown()), - }), -}); - -const walletActionRequestSchema = z.object({ - jsonrpc: z.literal("2.0"), - id: z.string(), - method: z.literal("fc_requestWalletAction"), - params: z.object({ - action: z.union([ - ethSendTransactionActionSchema, - ethSignTypedDataV4ActionSchema, - ]), - }), -}); - -const createCastRequestSchema = z.object({ - jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number(), z.null()]), - method: z.literal("fc_createCast"), - params: z.object({ - cast: z.object({ - parent: z.string().optional(), - text: z.string(), - embeds: z.array(z.string().min(1).url()).min(1), - }), - }), -}); - -const composerActionMessageSchema = z.union([ - createCastRequestSchemaLegacy, - walletActionRequestSchema, - createCastRequestSchema, -]); +import { useComposerAction } from "@frames.js/render/use-composer-action"; +import type { ComposerActionState } from "frames.js/types"; +import { useRef } from "react"; +import { + useAccount, + useChainId, + useSendTransaction, + useSignTypedData, + useSwitchChain, +} from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useToast } from "@/components/ui/use-toast"; +import { ToastAction } from "@/components/ui/toast"; +import { parseEther } from "viem"; +import { parseChainId } from "../lib/utils"; +import { FarcasterMultiSignerInstance } from "@frames.js/render/identity/farcaster"; +import { AlertTriangleIcon, Loader2Icon } from "lucide-react"; type ComposerFormActionDialogProps = { - composerActionForm: ComposerActionFormResponse; + actionState: ComposerActionState; + url: string; + signer: FarcasterMultiSignerInstance; onClose: () => void; - onSave: (arg: { composerState: ComposerActionState }) => void; - onTransaction?: OnTransactionFunc; - onSignature?: OnSignatureFunc; - // TODO: Consider moving this into return value of onTransaction - connectedAddress?: `0x${string}`; + onSubmit: (actionState: ComposerActionState) => void; + onToggleToCastActionDebugger: () => void; }; -export function ComposerFormActionDialog({ - composerActionForm, +export const ComposerFormActionDialog = ({ + actionState, + signer, + url, onClose, - onSave, - onTransaction, - onSignature, - connectedAddress, -}: ComposerFormActionDialogProps) { - const onSaveRef = useRef(onSave); - onSaveRef.current = onSave; - + onSubmit, + onToggleToCastActionDebugger, +}: ComposerFormActionDialogProps) => { const iframeRef = useRef(null); + const { toast } = useToast(); + const account = useAccount(); + const currentChainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const { sendTransactionAsync } = useSendTransaction(); + const { signTypedDataAsync } = useSignTypedData(); + const { openConnectModal } = useConnectModal(); + + const result = useComposerAction({ + url, + enabled: !signer.isLoadingSigner, + proxyUrl: "/frames", + actionState, + signer, + async resolveAddress() { + if (account.address) { + return account.address; + } - const postMessageToIframe = useCallback( - (message: any) => { - if (iframeRef.current && iframeRef.current.contentWindow) { - iframeRef.current.contentWindow.postMessage( - message, - new URL(composerActionForm.url).origin - ); + if (!openConnectModal) { + throw new Error("Connect modal is not available"); } + + openConnectModal(); + + return null; }, - [composerActionForm.url] - ); + onError(error) { + console.error(error); + + if ( + error.message.includes( + "Unexpected composer action response from the server" + ) + ) { + toast({ + title: "Error occurred", + description: + "It seems that you tried to call a cast action in the composer action debugger.", + variant: "destructive", + action: ( + { + onToggleToCastActionDebugger(); + }} + > + Switch + + ), + }); - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.origin !== new URL(composerActionForm.url).origin) { return; } - const result = composerActionMessageSchema.safeParse(event.data); + toast({ + title: "Error occurred", + description: ( +
    +

    {error.message}

    +

    Please check the console for more information

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

    + Something went wrong +

    +

    Check the console

    +
    + + )} + {result.status === "success" && ( + <> + + {result.data.title} + +
    + +
    + + + {new URL(result.data.url).hostname} + + + + )}
    ); -} +}; + +ComposerFormActionDialog.displayName = "ComposerFormActionDialog"; diff --git a/packages/debugger/app/debugger-page.tsx b/packages/debugger/app/debugger-page.tsx index afba6a646..cd390136b 100644 --- a/packages/debugger/app/debugger-page.tsx +++ b/packages/debugger/app/debugger-page.tsx @@ -56,7 +56,6 @@ import type { import { useAnonymousIdentity } from "@frames.js/render/identity/anonymous"; import { useFarcasterFrameContext, - useFarcasterMultiIdentity, type FarcasterSigner, } from "@frames.js/render/identity/farcaster"; import { @@ -67,25 +66,14 @@ import { useXmtpFrameContext, useXmtpIdentity, } from "@frames.js/render/identity/xmtp"; +import { useFarcasterIdentity } from "./hooks/useFarcasterIdentity"; +import { InvalidChainIdError, parseChainId } from "./lib/utils"; const FALLBACK_URL = process.env.NEXT_PUBLIC_DEBUGGER_DEFAULT_URL || "http://localhost:3000"; -class InvalidChainIdError extends Error {} class CouldNotChangeChainError extends Error {} -function isValidChainId(id: string): boolean { - return id.startsWith("eip155:"); -} - -function parseChainId(id: string): number { - if (!isValidChainId(id)) { - throw new InvalidChainIdError(`Invalid chainId ${id}`); - } - - return parseInt(id.split("eip155:")[1]!); -} - const anonymousFrameContext = {}; export default function DebuggerPage({ @@ -257,26 +245,8 @@ export default function DebuggerPage({ refreshUrl(url); }, [url, protocolConfiguration, refreshUrl, toast, debuggerConsole]); - const farcasterSignerState = useFarcasterMultiIdentity({ - onMissingIdentity() { - toast({ - title: "Please select an identity", - description: - "In order to test the buttons you need to select an identity first", - variant: "destructive", - action: ( - { - selectProtocolButtonRef.current?.click(); - }} - type="button" - > - Select identity - - ), - }); - }, + const farcasterSignerState = useFarcasterIdentity({ + selectProtocolButtonRef, }); const xmtpSignerState = useXmtpIdentity(); const lensSignerState = useLensIdentity(); @@ -301,8 +271,6 @@ export default function DebuggerPage({ }, }); - const anonymousFrameContext = {}; - const onConnectWallet: OnConnectWalletFunc = useCallback(async () => { if (!openConnectModal) { throw new Error(`openConnectModal not implemented`); @@ -640,7 +608,6 @@ export default function DebuggerPage({ }; }, [ anonymousSignerState, - anonymousFrameContext, farcasterFrameConfig, lensFrameContext.frameContext, lensSignerState, diff --git a/packages/debugger/app/frames/route.ts b/packages/debugger/app/frames/route.ts index c486a7551..b6f987512 100644 --- a/packages/debugger/app/frames/route.ts +++ b/packages/debugger/app/frames/route.ts @@ -1,51 +1,11 @@ -import { type FrameActionPayload } from "frames.js"; +import { POST as handlePOSTRequest } from "@frames.js/render/next"; import { type NextRequest } from "next/server"; import { getAction } from "../actions/getAction"; import { persistMockResponsesForDebugHubRequests } from "../utils/mock-hub-utils"; import type { SupportedParsingSpecification } from "frames.js"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; -import { z } from "zod"; import type { ParseActionResult } from "../actions/types"; import type { ParseFramesWithReportsResult } from "frames.js/frame-parsers"; -import type { JsonObject } from "frames.js/types"; - -const castActionMessageParser = z.object({ - type: z.literal("message"), - message: z.string().min(1), -}); - -const castActionFrameParser = z.object({ - type: z.literal("frame"), - frameUrl: z.string().min(1).url(), -}); - -const composerActionFormParser = z.object({ - type: z.literal("form"), - url: z.string().min(1).url(), - title: z.string().min(1), -}); - -const jsonResponseParser = z.preprocess( - (data) => { - if (typeof data === "object" && data !== null && !("type" in data)) { - return { - type: "message", - ...data, - }; - } - - return data; - }, - z.discriminatedUnion("type", [ - castActionFrameParser, - castActionMessageParser, - composerActionFormParser, - ]) -); - -const errorResponseParser = z.object({ - message: z.string().min(1), -}); export type CastActionDefinitionResponse = ParseActionResult & { type: "action"; @@ -126,129 +86,7 @@ export async function GET(request: NextRequest): Promise { /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: NextRequest): Promise { - const body = (await req.clone().json()) as FrameActionPayload; - const isPostRedirect = - req.nextUrl.searchParams.get("postType") === "post_redirect"; - const isTransactionRequest = - req.nextUrl.searchParams.get("postType") === "tx"; - const postUrl = req.nextUrl.searchParams.get("postUrl"); - const specification = - req.nextUrl.searchParams.get("specification") ?? "farcaster"; - - if (!isSpecificationValid(specification)) { - return Response.json({ message: "Invalid specification" }, { status: 400 }); - } - - // TODO: refactor useful logic back into render package - - if (specification === "farcaster") { - await persistMockResponsesForDebugHubRequests(req); - } + await persistMockResponsesForDebugHubRequests(req); - if (!postUrl) { - return Response.json({ message: "Invalid post URL" }, { status: 400 }); - } - - try { - const r = await fetch(postUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - redirect: isPostRedirect ? "manual" : undefined, - body: JSON.stringify(body), - }); - - if (r.status === 302) { - return Response.json( - { - location: r.headers.get("location"), - }, - { status: 302 } - ); - } - - // this is an error, just return response as is - if (r.status >= 500) { - return Response.json(await r.text(), { status: r.status }); - } - - if (r.status >= 400 && r.status < 500) { - const parseResult = await z - .promise(errorResponseParser) - .safeParseAsync(r.clone().json()); - - if (!parseResult.success) { - return Response.json( - { message: await r.clone().text() }, - { status: r.status } - ); - } - - const headers = new Headers(r.headers); - // Proxied requests could have content-encoding set, which breaks the response - headers.delete("content-encoding"); - return new Response(r.body, { - headers, - status: r.status, - statusText: r.statusText, - }); - } - - if (isPostRedirect && r.status !== 302) { - return Response.json( - { - message: `Invalid response status code for post redirect button, 302 expected, got ${r.status}`, - }, - { status: 400 } - ); - } - - if (isTransactionRequest) { - const transaction = (await r.json()) as JsonObject; - return Response.json(transaction); - } - - // Content type is JSON, could be an action - if (r.headers.get("content-type")?.includes("application/json")) { - const parseResult = await z - .promise(jsonResponseParser) - .safeParseAsync(r.clone().json()); - - if (!parseResult.success) { - throw new Error("Invalid frame response"); - } - - const headers = new Headers(r.headers); - // Proxied requests could have content-encoding set, which breaks the response - headers.delete("content-encoding"); - return new Response(r.body, { - headers, - status: r.status, - statusText: r.statusText, - }); - } - - const html = await r.text(); - - const parseResult = parseFramesWithReports({ - html, - fallbackPostUrl: body.untrustedData.url, - fromRequestMethod: "POST", - }); - - return Response.json({ - type: "frame", - ...parseResult, - } satisfies FrameDefinitionResponse); - } catch (err) { - // eslint-disable-next-line no-console -- provide feedback to the user - console.error(err); - return Response.json( - { - message: String(err), - }, - { status: 500 } - ); - } + return handlePOSTRequest(req); } diff --git a/packages/debugger/app/hooks/useDebuggerFrameState.ts b/packages/debugger/app/hooks/useDebuggerFrameState.ts new file mode 100644 index 000000000..554343953 --- /dev/null +++ b/packages/debugger/app/hooks/useDebuggerFrameState.ts @@ -0,0 +1,150 @@ +import type { + FrameState, + FrameStateAPI, + UseFrameStateOptions, +} from "@frames.js/render/unstable-types"; +import { useFrameState } from "@frames.js/render/unstable-use-frame-state"; + +function computeDurationInSeconds(start: Date, end: Date): number { + return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); +} + +type ExtraPending = { + startTime: Date; + requestDetails: { + body?: object; + searchParams?: URLSearchParams; + }; +}; + +type SharedResponseExtra = { + response: Response; + responseStatus: number; + responseBody: unknown; + /** + * The speed of the response in seconds + */ + speed: number; +}; + +type ExtraDone = SharedResponseExtra; + +type ExtraDoneRedirect = SharedResponseExtra; + +type ExtraRequestError = Pick & { + response: Response | null; + responseStatus: number; + responseBody: unknown; +}; + +type ExtraMessage = SharedResponseExtra; + +type DebuggerFrameState = FrameState< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage +>; +type DebuggerFrameStateAPI = FrameStateAPI< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage +>; + +type DebuggerFrameStateOptions = Omit< + UseFrameStateOptions< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage + >, + "resolveDoneExtra" +>; + +export function useDebuggerFrameState( + options: DebuggerFrameStateOptions +): [DebuggerFrameState, DebuggerFrameStateAPI] { + return useFrameState< + ExtraPending, + ExtraDone, + ExtraDoneRedirect, + ExtraRequestError, + ExtraMessage + >({ + ...options, + resolveGETPendingExtra() { + return { + startTime: new Date(), + requestDetails: {}, + }; + }, + resolvePOSTPendingExtra(arg) { + return { + startTime: new Date(), + requestDetails: { + body: arg.action.body, + searchParams: arg.action.searchParams, + }, + }; + }, + resolveDoneExtra(arg) { + return { + response: arg.response.clone(), + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + responseBody: arg.responseBody, + responseStatus: arg.response.status, + }; + }, + resolveDoneRedirectExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseStatus: arg.response.status, + responseBody: arg.responseBody, + }; + }, + resolveDoneWithErrorMessageExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseBody: arg.responseData, + responseStatus: arg.response.status, + }; + }, + resolveFailedExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response?.clone() ?? null, + responseBody: arg.responseBody, + responseStatus: arg.responseStatus, + }; + }, + resolveFailedWithRequestErrorExtra(arg) { + return { + speed: computeDurationInSeconds( + arg.pendingItem.extra.startTime, + arg.endTime + ), + response: arg.response.clone(), + responseBody: arg.responseBody, + responseStatus: arg.response.status, + }; + }, + }); +} diff --git a/packages/debugger/app/hooks/useFarcasterIdentity.tsx b/packages/debugger/app/hooks/useFarcasterIdentity.tsx new file mode 100644 index 000000000..b3a1f544b --- /dev/null +++ b/packages/debugger/app/hooks/useFarcasterIdentity.tsx @@ -0,0 +1,44 @@ +import { ToastAction } from "@/components/ui/toast"; +import { useToast } from "@/components/ui/use-toast"; +import { useFarcasterMultiIdentity } from "@frames.js/render/identity/farcaster"; +import { WebStorage } from "@frames.js/render/identity/storage"; + +const sharedStorage = new WebStorage(); + +type Options = Omit< + Parameters[0], + "onMissingIdentity" +> & { + selectProtocolButtonRef?: React.RefObject; +}; + +export function useFarcasterIdentity({ + selectProtocolButtonRef, + ...options +}: Options = {}) { + const { toast } = useToast(); + + return useFarcasterMultiIdentity({ + ...(options ?? {}), + storage: sharedStorage, + onMissingIdentity() { + toast({ + title: "Please select an identity", + description: + "In order to test the buttons you need to select an identity first", + variant: "destructive", + action: selectProtocolButtonRef?.current ? ( + { + selectProtocolButtonRef?.current?.click(); + }} + type="button" + > + Select identity + + ) : undefined, + }); + }, + }); +} diff --git a/packages/debugger/app/lib/utils.ts b/packages/debugger/app/lib/utils.ts index 0c58f89ad..7d5402eff 100644 --- a/packages/debugger/app/lib/utils.ts +++ b/packages/debugger/app/lib/utils.ts @@ -18,3 +18,17 @@ export function hasWarnings(reports: Record): boolean { report.some((r) => r.level === "warning") ); } + +export class InvalidChainIdError extends Error {} + +export function isValidChainId(id: string): boolean { + return id.startsWith("eip155:"); +} + +export function parseChainId(id: string): number { + if (!isValidChainId(id)) { + throw new InvalidChainIdError(`Invalid chainId ${id}`); + } + + return parseInt(id.split("eip155:")[1]!); +} diff --git a/packages/debugger/app/utils/mock-hub-utils.ts b/packages/debugger/app/utils/mock-hub-utils.ts index 62001f16c..f57c0fab0 100644 --- a/packages/debugger/app/utils/mock-hub-utils.ts +++ b/packages/debugger/app/utils/mock-hub-utils.ts @@ -71,14 +71,17 @@ export async function loadMockResponseForDebugHubRequest( } export async function persistMockResponsesForDebugHubRequests(req: Request) { - const { - mockData, - untrustedData: { fid: requesterFid, castId }, - } = (await req.clone().json()) as { - mockData: MockHubActionContext; - untrustedData: { fid: string; castId: { fid: string; hash: string } }; + const { mockData, untrustedData } = (await req.clone().json()) as { + mockData?: MockHubActionContext; + untrustedData?: { fid: string; castId?: { fid: string; hash: string } }; }; + if (!mockData || !untrustedData?.castId) { + return; + } + + const { fid: requesterFid, castId } = untrustedData; + const requesterFollowsCaster = `/v1/linkById?${sortedSearchParamsString( new URLSearchParams({ fid: requesterFid, diff --git a/packages/render/package.json b/packages/render/package.json index 22aa74646..fe85535c1 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -130,6 +130,16 @@ "default": "./dist/use-frame.cjs" } }, + "./use-composer-action": { + "import": { + "types": "./dist/use-composer-action.d.ts", + "default": "./dist/use-composer-action.js" + }, + "require": { + "types": "./dist/use-composer-action.d.cts", + "default": "./dist/use-composer-action.cjs" + } + }, "./unstable-use-frame-state": { "import": { "types": "./dist/unstable-use-frame-state.d.ts", @@ -275,6 +285,7 @@ "dependencies": { "@farcaster/core": "^0.14.7", "@noble/ed25519": "^2.0.0", - "frames.js": "^0.19.5" + "frames.js": "^0.19.5", + "zod": "^3.23.8" } } diff --git a/packages/render/src/errors.ts b/packages/render/src/errors.ts index ad7dd010b..f6f8f74e3 100644 --- a/packages/render/src/errors.ts +++ b/packages/render/src/errors.ts @@ -33,3 +33,9 @@ export class ComposerActionUnexpectedResponseError extends Error { super("Unexpected composer action response from the server"); } } + +export class ComposerActionUserRejectedRequestError extends Error { + constructor() { + super("User rejected the request"); + } +} diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx index 7ccb9a013..64f3ec037 100644 --- a/packages/render/src/farcaster/frames.tsx +++ b/packages/render/src/farcaster/frames.tsx @@ -7,7 +7,7 @@ import { getFarcasterTime, makeFrameAction, } from "@farcaster/core"; -import { hexToBytes } from "viem"; +import { bytesToHex, hexToBytes } from "viem"; import type { FrameActionBodyPayload, FrameContext, @@ -15,9 +15,45 @@ import type { SignerStateActionContext, SignFrameActionFunc, } from "../types"; +import type { + SignComposerActionFunc, + SignerStateComposerActionContext, +} from "../unstable-types"; +import { tryCallAsync } from "../helpers"; import type { FarcasterSigner } from "./signers"; import type { FarcasterFrameContext } from "./types"; +/** + * Creates a singer request payload to fetch composer action url. + */ +export const signComposerAction: SignComposerActionFunc = + async function signComposerAction(signerPrivateKey, actionContext) { + const messageOrError = await tryCallAsync(() => + createComposerActionMessageWithSignerKey(signerPrivateKey, actionContext) + ); + + if (messageOrError instanceof Error) { + throw messageOrError; + } + + const { message, trustedBytes } = messageOrError; + + return { + untrustedData: { + buttonIndex: 1, + fid: actionContext.fid, + messageHash: bytesToHex(message.hash), + network: 1, + state: Buffer.from(message.data.frameActionBody.state).toString(), + timestamp: new Date().getTime(), + url: actionContext.url, + }, + trustedData: { + messageBytes: trustedBytes, + }, + }; + }; + /** Creates a frame action for use with `useFrame` and a proxy */ export const signFrameAction: SignFrameActionFunc< FarcasterSigner, @@ -104,6 +140,43 @@ export const signFrameAction: SignFrameActionFunc< }; }; +export async function createComposerActionMessageWithSignerKey( + signerKey: string, + { fid, state, url }: SignerStateComposerActionContext +): Promise<{ + message: FrameActionMessage; + trustedBytes: string; +}> { + const signer = new NobleEd25519Signer(Buffer.from(signerKey.slice(2), "hex")); + + const messageDataOptions = { + fid, + network: FarcasterNetwork.MAINNET, + }; + + const message = await makeFrameAction( + FrameActionBody.create({ + url: Buffer.from(url), + buttonIndex: 1, + state: Buffer.from(encodeURIComponent(JSON.stringify({ cast: state }))), + }), + messageDataOptions, + signer + ); + + if (message.isErr()) { + throw message.error; + } + + const messageData = message.value; + + const trustedBytes = Buffer.from( + Message.encode(message._unsafeUnwrap()).finish() + ).toString("hex"); + + return { message: messageData, trustedBytes }; +} + export async function createFrameActionMessageWithSignerKey( signerKey: string, { diff --git a/packages/render/src/farcaster/signers.tsx b/packages/render/src/farcaster/signers.tsx index 08cd75195..58fe6b2a6 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,4 +1,5 @@ import type { FrameActionBodyPayload, SignerStateInstance } from "../types"; +import type { SignComposerActionFunc } from "../unstable-types"; import type { FarcasterFrameContext } from "./types"; export type FarcasterSignerState = @@ -6,7 +7,9 @@ export type FarcasterSignerState = TSignerType, FrameActionBodyPayload, FarcasterFrameContext - >; + > & { + signComposerAction: SignComposerActionFunc; + }; export type FarcasterSignerPendingApproval = { status: "pending_approval"; diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index 3346cc3ba..c18382b23 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -2,6 +2,7 @@ import type { ParseFramesWithReportsResult, ParseResult, } from "frames.js/frame-parsers"; +import type { ComposerActionFormResponse } from "frames.js/types"; import type { PartialFrame } from "./ui/types"; export async function tryCallAsync( @@ -71,3 +72,37 @@ export function isPartialFrame( value.frame.buttons.length > 0 ); } + +export function isComposerFormActionResponse( + response: unknown +): response is ComposerActionFormResponse { + return ( + typeof response === "object" && + response !== null && + "type" in response && + response.type === "form" + ); +} + +/** + * Merges all search params in order from left to right into the URL. + * + * @param url - The URL to merge the search params into. Either fully qualified or path only. + */ +export function mergeSearchParamsToUrl( + url: string, + ...searchParams: URLSearchParams[] +): string { + const temporaryDomain = "temporary-for-parsing-purposes.tld"; + const parsedProxyUrl = new URL(url, `http://${temporaryDomain}`); + + searchParams.forEach((params) => { + params.forEach((value, key) => { + parsedProxyUrl.searchParams.set(key, value); + }); + }); + + return parsedProxyUrl.hostname === temporaryDomain + ? `${parsedProxyUrl.pathname}${parsedProxyUrl.search}` + : parsedProxyUrl.toString(); +} diff --git a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx index a3bcb0226..db3d73b82 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -187,16 +188,12 @@ export function useFarcasterIdentity({ return value; }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -300,7 +297,7 @@ export function useFarcasterIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -329,14 +326,14 @@ export function useFarcasterIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -354,7 +351,7 @@ export function useFarcasterIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const farcasterUser = state.status === "init" ? null : state; @@ -418,6 +415,7 @@ export function useFarcasterIdentity({ visibilityDetector, setState, enableIdentityPolling, + onLogInRef, ]); return useMemo( @@ -428,6 +426,7 @@ export function useFarcasterIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, diff --git a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx index 1a96b68b9..b5142bbf2 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -259,20 +260,14 @@ export function useFarcasterMultiIdentity({ identities: [], }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const onIdentityRemoveRef = useRef(onIdentityRemove); - onIdentityRemoveRef.current = onIdentityRemove; - const onIdentitySelectRef = useRef(onIdentitySelect); - onIdentitySelectRef.current = onIdentitySelect; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const onIdentityRemoveRef = useFreshRef(onIdentityRemove); + const onIdentitySelectRef = useFreshRef(onIdentitySelect); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -388,7 +383,7 @@ export function useFarcasterMultiIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -417,14 +412,14 @@ export function useFarcasterMultiIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -442,7 +437,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const removeIdentity = useCallback(async () => { await setState((currentState) => { @@ -452,7 +447,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "REMOVE" }); }); - }, [setState]); + }, [onIdentityRemoveRef, setState]); const farcasterUser = state.activeIdentity; @@ -534,6 +529,7 @@ export function useFarcasterMultiIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, diff --git a/packages/render/src/mini-app-messages.ts b/packages/render/src/mini-app-messages.ts new file mode 100644 index 000000000..d2a55ea0b --- /dev/null +++ b/packages/render/src/mini-app-messages.ts @@ -0,0 +1,139 @@ +import type { Abi, TypedData, TypedDataDomain } from "viem"; +import { z } from "zod"; + +export type TransactionResponse = + | TransactionResponseSuccess + | TransactionResponseFailure; + +export type TransactionResponseSuccess = { + jsonrpc: "2.0"; + id: string | number | null; + result: TransactionSuccessBody; +}; + +export type TransactionSuccessBody = + | EthSendTransactionSuccessBody + | EthSignTypedDataV4SuccessBody; + +export type EthSendTransactionSuccessBody = { + address: `0x${string}`; + transactionHash: `0x${string}`; +}; + +export type EthSignTypedDataV4SuccessBody = { + address: `0x${string}`; + signature: `0x${string}`; +}; + +export type TransactionResponseFailure = { + jsonrpc: "2.0"; + id: string | number | null; + error: { + code: number; + message: string; + }; +}; + +export type CreateCastResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result: { + success: true; + }; +}; + +export type MiniAppResponse = TransactionResponse | CreateCastResponse; + +const createCastRequestSchemaLegacy = z.object({ + type: z.literal("createCast"), + data: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastLegacyMessage = z.infer< + typeof createCastRequestSchemaLegacy +>; + +const createCastRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number(), z.null()]), + method: z.literal("fc_createCast"), + params: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastMessage = z.infer; + +const ethSendTransactionActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_sendTransaction"), + attribution: z.boolean().optional(), + params: z.object({ + abi: z.custom(), + to: z.custom<`0x${string}`>( + (val): val is `0x${string}` => + typeof val === "string" && val.startsWith("0x") + ), + value: z.string().optional(), + data: z + .custom<`0x${string}`>((val): val is `0x${string}` => typeof val === "string" && val.startsWith("0x")) + .optional(), + }), +}); + +export type EthSendTransactionAction = z.infer< + typeof ethSendTransactionActionSchema +>; + +const ethSignTypedDataV4ActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_signTypedData_v4"), + params: z.object({ + domain: z.custom(), + types: z.custom((value) => { + const result = z.record(z.unknown()).safeParse(value); + + return result.success; + }), + primaryType: z.string(), + message: z.record(z.unknown()), + }), +}); + +export type EthSignTypedDataV4Action = z.infer< + typeof ethSignTypedDataV4ActionSchema +>; + +const walletActionRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string(), + method: z.literal("fc_requestWalletAction"), + params: z.object({ + action: z.union([ + ethSendTransactionActionSchema, + ethSignTypedDataV4ActionSchema, + ]), + }), +}); + +export type RequestWalletActionMessage = z.infer< + typeof walletActionRequestSchema +>; + +export const miniAppMessageSchema = z.union([ + createCastRequestSchemaLegacy, + walletActionRequestSchema, + createCastRequestSchema, +]); + +export type MiniAppMessage = z.infer; diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index c0d61e9f7..e9f216bb0 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -7,9 +7,48 @@ import { type ParseFramesWithReportsResult } from "frames.js/frame-parsers"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import type { JsonObject, JsonValue } from "frames.js/types"; import type { NextRequest } from "next/server"; +import { z } from "zod"; import { tryCallAsync } from "../helpers"; import { isSpecificationValid } from "./validators"; +const castActionMessageParser = z.object({ + type: z.literal("message"), + message: z.string().min(1), +}); + +const castActionFrameParser = z.object({ + type: z.literal("frame"), + frameUrl: z.string().min(1).url(), +}); + +const composerActionFormParser = z.object({ + type: z.literal("form"), + url: z.string().min(1).url(), + title: z.string().min(1), +}); + +const jsonResponseParser = z.preprocess( + (data) => { + if (typeof data === "object" && data !== null && !("type" in data)) { + return { + type: "message", + ...data, + }; + } + + return data; + }, + z.discriminatedUnion("type", [ + castActionFrameParser, + castActionMessageParser, + composerActionFormParser, + ]) +); + +const errorResponseParser = z.object({ + message: z.string().min(1), +}); + export type POSTResponseError = { message: string }; export type POSTResponseRedirect = { location: string }; @@ -23,15 +62,6 @@ export type POSTResponse = | POSTResponseRedirect | JsonObject; -function isJsonErrorObject(data: JsonValue): data is { message: string } { - return ( - typeof data === "object" && - data !== null && - "message" in data && - typeof data.message === "string" - ); -} - /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: Request | NextRequest): Promise { try { @@ -78,9 +108,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -136,9 +168,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -178,6 +212,32 @@ export async function POST(req: Request | NextRequest): Promise { return Response.json(transaction satisfies JsonObject); } + // Content type is JSON, could be an action + if ( + response.headers + .get("content-type") + ?.toLowerCase() + .includes("application/json") + ) { + const parseResult = await z + .promise(jsonResponseParser) + .safeParseAsync(response.clone().json()); + + if (!parseResult.success) { + throw new Error("Invalid frame response"); + } + + const headers = new Headers(response.headers); + // Proxied requests could have content-encoding set, which breaks the response + headers.delete("content-encoding"); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } + const html = await response.text(); if (multiSpecificationEnabled) { diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts index b85c79107..6a524d703 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -433,6 +433,7 @@ export type FrameRequest< > = FrameGETRequest | FramePOSTRequest; export type FrameStackBase = { + id: number; timestamp: Date; /** speed in seconds */ speed: number; @@ -446,6 +447,7 @@ export type FrameStackBase = { }; export type FrameStackPostPending = { + id: number; method: "POST"; timestamp: Date; status: "pending"; @@ -458,6 +460,7 @@ export type FrameStackPostPending = { }; export type FrameStackGetPending = { + id: number; method: "GET"; timestamp: Date; status: "pending"; diff --git a/packages/render/src/ui/frame.base.tsx b/packages/render/src/ui/frame.base.tsx index 26d2309d5..91e1fd0ec 100644 --- a/packages/render/src/ui/frame.base.tsx +++ b/packages/render/src/ui/frame.base.tsx @@ -28,7 +28,9 @@ export type FrameUITheme> = Partial>; export type BaseFrameUIProps> = { - frameState: FrameState | UseFrameReturnValue; + frameState: + | FrameState + | UseFrameReturnValue; /** * Renders also frames that contain only image and at least one button * @@ -141,7 +143,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -173,7 +175,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; @@ -182,7 +184,7 @@ export function BaseFrameUI>({ if (!currentFrameStackItem.request.sourceFrame) { frameUiState = { status: "loading", - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -190,7 +192,7 @@ export function BaseFrameUI>({ status: "complete", frame: currentFrameStackItem.request.sourceFrame, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } @@ -206,7 +208,7 @@ export function BaseFrameUI>({ ? currentFrameStackItem.frameResult.framesDebugInfo?.image : undefined, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else if ( @@ -220,7 +222,7 @@ export function BaseFrameUI>({ ? currentFrameStackItem.frameResult.framesDebugInfo?.image : undefined, isImageLoading, - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; } else { @@ -235,7 +237,7 @@ export function BaseFrameUI>({ case "pending": { frameUiState = { status: "loading", - id: currentFrameStackItem.timestamp.getTime(), + id: currentFrameStackItem.id, frameState, }; break; diff --git a/packages/render/src/ui/utils.ts b/packages/render/src/ui/utils.ts index c3a6db5c3..4abf00680 100644 --- a/packages/render/src/ui/utils.ts +++ b/packages/render/src/ui/utils.ts @@ -5,6 +5,11 @@ import type { FrameStackMessage, FrameStackRequestError, } from "../types"; +import type { + FramesStackItem as UnstableFramesStackItem, + FrameStackMessage as UnstableFrameStackMessage, + FrameStackRequestError as UnstableFrameStackRequestError, +} from "../unstable-types"; import type { PartialFrame } from "./types"; type FrameResultFailure = Exclude; @@ -16,7 +21,7 @@ type FrameStackItemWithPartialFrame = Omit & { }; export function isPartialFrameStackItem( - stackItem: FramesStackItem + stackItem: FramesStackItem | UnstableFramesStackItem ): stackItem is FrameStackItemWithPartialFrame { return ( stackItem.status === "done" && @@ -28,7 +33,11 @@ export function isPartialFrameStackItem( } export function getErrorMessageFromFramesStackItem( - item: FrameStackMessage | FrameStackRequestError + item: + | FrameStackMessage + | FrameStackRequestError + | UnstableFrameStackMessage + | UnstableFrameStackRequestError ): string { if (item.status === "message") { return item.message; diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index 8e3d62072..8433fed8f 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -11,23 +11,23 @@ import type { ParseResultWithFrameworkDetails, } from "frames.js/frame-parsers"; import type { Dispatch } from "react"; +import type { + ComposerActionState, + ErrorMessageResponse, +} from "frames.js/types"; import type { ButtonPressFunction, FrameContext, + FrameGETRequest, + FramePOSTRequest, FrameRequest, - FrameStackBase, - FrameStackDoneRedirect, - FrameStackMessage, - FrameStackPending, - FrameStackRequestError, - OnConnectWalletFunc, OnMintArgs, OnSignatureFunc, OnTransactionFunc, + SignedFrameAction, SignerStateActionContext, SignerStateInstance, } from "./types"; -import type { FrameState, FrameStateAPI } from "./unstable-use-frame-state"; export type ResolvedSigner = { /** @@ -48,7 +48,36 @@ export type ResolveSignerFunction = ( arg: ResolveSignerFunctionArg ) => ResolvedSigner; -export type UseFrameOptions = { +export type ResolveAddressFunction = () => Promise<`0x${string}` | null>; + +export type UseFrameOptions< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataDoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +> = { + /** + * The frame state to be used for the frame. Allows to store extra data on stack items. + */ + frameStateHook?: ( + options: Pick< + UseFrameStateOptions< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >, + "initialFrameUrl" | "initialParseResult" | "resolveSpecification" + > + ) => UseFrameStateReturn< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >; /** the route used to POST frame actions. The post_url will be added as a the `url` query parameter */ frameActionProxy: string; /** the route used to GET the initial frame via proxy */ @@ -75,9 +104,13 @@ export type UseFrameOptions = { */ frame?: ParseFramesWithReportsResult | null; /** - * connected wallet address of the user, send to the frame for transaction requests + * Called before onTransaction/onSignature is invoked to obtain an address to use. + * + * If the function returns null onTransaction/onSignature will not be called. + * + * Sent to the frame on transaction requests. */ - connectedAddress: `0x${string}` | undefined; + resolveAddress: ResolveAddressFunction; /** a function to handle mint buttons */ onMint?: (t: OnMintArgs) => void; /** a function to handle transaction buttons that returned transaction data from the target, returns the transaction hash or null */ @@ -86,10 +119,6 @@ export type UseFrameOptions = { transactionDataSuffix?: `0x${string}`; /** A function to handle transaction buttons that returned signature data from the target, returns signature hash or null */ onSignature?: OnSignatureFunc; - /** - * Called when user presses transaction button but there is no wallet connected. - */ - onConnectWallet?: OnConnectWalletFunc; /** * Extra data appended to the frame action payload */ @@ -119,21 +148,84 @@ export type UseFrameOptions = { > >; -export type FrameStackDone = FrameStackBase & { +export type FrameStackBase = { + id: number; + url: string; +}; + +export type FrameStackPostPending = Omit< + FrameStackBase, + "responseStatus" | "responseBody" +> & { + method: "POST"; + status: "pending"; + request: FramePOSTRequest; + extra: TExtra; +}; + +export type FrameStackGetPending = Omit< + FrameStackBase, + "responseStatus" | "responseBody" +> & { + method: "GET"; + status: "pending"; + request: FrameGETRequest; + extra: TExtra; +}; + +export type FrameStackPending = + | FrameStackGetPending + | FrameStackPostPending; + +export type FrameStackDone = FrameStackBase & { request: FrameRequest; - response: Response; frameResult: ParseResultWithFrameworkDetails; status: "done"; + extra: TExtra; +}; + +export type FrameStackDoneRedirect = FrameStackBase & { + request: FramePOSTRequest; + location: string; + status: "doneRedirect"; + extra: TExtra; +}; + +export type FrameStackRequestError = FrameStackBase & { + request: FrameRequest; + status: "requestError"; + requestError: Error; + extra: TExtra; +}; + +export type FrameStackMessage = FrameStackBase & { + request: FramePOSTRequest; + status: "message"; + message: string; + type: "info" | "error"; + extra: TExtra; }; -export type FramesStackItem = - | FrameStackPending - | FrameStackDone - | FrameStackDoneRedirect - | FrameStackRequestError - | FrameStackMessage; +export type FramesStackItem< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = + | FrameStackPending + | FrameStackDone + | FrameStackDoneRedirect + | FrameStackRequestError + | FrameStackMessage; -export type UseFrameReturnValue = { +export type UseFrameReturnValue< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataDoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +> = { /** * The signer state is set once it is resolved (on initial frame render) */ @@ -144,11 +236,25 @@ export type UseFrameReturnValue = { readonly specification: SupportedParsingSpecification | undefined; fetchFrame: FetchFrameFunction; clearFrameStack: () => void; - dispatchFrameStack: Dispatch; + dispatchFrameStack: Dispatch< + FrameReducerActions< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + > + >; /** The frame at the top of the stack (at index 0) */ readonly currentFrameStackItem: FramesStackItem | undefined; /** A stack of frames with additional context, with the most recent frame at index 0 */ - readonly framesStack: FramesStack; + readonly framesStack: FramesStack< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage + >; readonly inputText: string; setInputText: (s: string) => void; onButtonPress: ButtonPressFunction>; @@ -159,34 +265,51 @@ export type UseFrameReturnValue = { reset: () => void; }; -export type FramesStack = FramesStackItem[]; +export type FramesStack< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = FramesStackItem< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>[]; -export type FrameReducerActions = +export type FrameReducerActions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMessage = unknown, +> = | { action: "LOAD"; - item: FrameStackPending; + item: FrameStackPending; } | { action: "REQUEST_ERROR"; - pendingItem: FrameStackPending; - item: FrameStackRequestError; + pendingItem: FrameStackPending; + item: FrameStackRequestError; } | { action: "DONE_REDIRECT"; - pendingItem: FrameStackPending; - item: FrameStackDoneRedirect; + pendingItem: FrameStackPending; + item: FrameStackDoneRedirect; } | { action: "DONE_WITH_ERROR_MESSAGE"; - pendingItem: FrameStackPending; - item: Exclude; + pendingItem: FrameStackPending; + item: Exclude, { type: "info" }>; } | { action: "DONE"; - pendingItem: FrameStackPending; + pendingItem: FrameStackPending; parseResult: ParseFramesWithReportsResult; - response: Response; - endTime: Date; + extra: TExtraDone; } | { action: "CLEAR" } | { @@ -196,11 +319,24 @@ export type FrameReducerActions = action: "RESET_INITIAL_FRAME"; parseResult: ParseFramesWithReportsResult; homeframeUrl: string; + extra: TExtraDone; }; -export type UseFetchFrameOptions = { +export type UseFetchFrameOptions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { frameState: FrameState; - frameStateAPI: FrameStateAPI; + frameStateAPI: FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; /** * URL or path to the frame proxy handling GET requests. */ @@ -313,3 +449,228 @@ export type FetchFrameFunction = ( */ shouldClear?: boolean ) => Promise; + +type MarkAsDoneArg = { + pendingItem: + | FrameStackGetPending + | FrameStackPostPending; + endTime: Date; + response: Response; + parseResult: ParseFramesWithReportsResult; + responseBody: unknown; +}; + +type MarkAsDonwWithRedirectArg = { + pendingItem: FrameStackPostPending; + endTime: Date; + location: string; + response: Response; + responseBody: unknown; +}; + +type MarkAsDoneWithErrorMessageArg = { + pendingItem: FrameStackPostPending; + endTime: Date; + response: Response; + responseData: ErrorMessageResponse; +}; + +type MarkAsFailedArg = { + pendingItem: + | FrameStackGetPending + | FrameStackPostPending; + endTime: Date; + requestError: Error; + response: Response | null; + responseBody: unknown; + responseStatus: number; +}; + +type MarkAsFailedWithRequestErrorArg = { + endTime: Date; + pendingItem: FrameStackPostPending; + error: Error; + response: Response; + responseBody: unknown; +}; + +type CreateGetPendingItemArg = { + request: FrameGETRequest; +}; + +type CreatePOSTPendingItemArg = { + action: SignedFrameAction; + request: FramePOSTRequest; + /** + * Optional, allows to override the start time + * + * @defaultValue new Date() + */ + startTime?: Date; +}; + +export type UseFrameStateOptions< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { + initialParseResult?: ParseFramesWithReportsResult | null; + initialFrameUrl?: string | null; + initialPendingExtra?: TExtraPending; + resolveSpecification: ResolveSignerFunction; + resolveGETPendingExtra?: (arg: CreateGetPendingItemArg) => TExtraPending; + resolvePOSTPendingExtra?: (arg: CreatePOSTPendingItemArg) => TExtraPending; + resolveDoneExtra?: (arg: MarkAsDoneArg) => TExtraDone; + resolveDoneRedirectExtra?: ( + arg: MarkAsDonwWithRedirectArg + ) => TExtraDoneRedirect; + resolveDoneWithErrorMessageExtra?: ( + arg: MarkAsDoneWithErrorMessageArg + ) => TExtraMesssage; + resolveFailedExtra?: ( + arg: MarkAsFailedArg + ) => TExtraRequestError; + resolveFailedWithRequestErrorExtra?: ( + arg: MarkAsFailedWithRequestErrorArg + ) => TExtraRequestError; +}; + +export type FrameStateAPI< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = { + dispatch: React.Dispatch< + FrameReducerActions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > + >; + clear: () => void; + createGetPendingItem: ( + arg: CreateGetPendingItemArg + ) => FrameStackGetPending; + createPostPendingItem: < + TSignerStateActionContext extends SignerStateActionContext, + >(arg: { + action: SignedFrameAction; + request: FramePOSTRequest; + /** + * Optional, allows to override the start time + * + * @defaultValue new Date() + */ + startTime?: Date; + }) => FrameStackPostPending; + markAsDone: (arg: MarkAsDoneArg) => void; + markAsDoneWithRedirect: ( + arg: MarkAsDonwWithRedirectArg + ) => void; + markAsDoneWithErrorMessage: ( + arg: MarkAsDoneWithErrorMessageArg + ) => void; + markAsFailed: (arg: MarkAsFailedArg) => void; + markAsFailedWithRequestError: ( + arg: MarkAsFailedWithRequestErrorArg + ) => void; + /** + * If arg is omitted it will reset the frame stack to initial frame and resolves the specification again. + * Otherwise it will set the frame state to provided values and resolve the specification. + */ + reset: (arg?: { + homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; + }) => void; +}; + +export type UseFrameStateReturn< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = [ + FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, + FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, +]; + +export type FrameState< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +> = + | { + type: "initialized"; + stack: FramesStack< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; + signerState: SignerStateInstance; + frameContext: FrameContext; + specification: SupportedParsingSpecification; + homeframeUrl: string; + parseResult: ParseFramesWithReportsResult; + } + | { + type: "not-initialized"; + stack: FramesStack< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >; + }; + +export type SignerStateComposerActionContext = { + fid: number; + url: string; + state: ComposerActionState; +}; + +export type SignerComposerActionResult = { + untrustedData: { + fid: number; + url: string; + messageHash: `0x${string}`; + timestamp: number; + network: number; + buttonIndex: 1; + state: string; + }; + trustedData: { + messageBytes: string; + }; +}; + +/** + * Used to sign composer action + */ +export type SignComposerActionFunc = ( + signerPrivateKey: string, + actionContext: SignerStateComposerActionContext +) => Promise; diff --git a/packages/render/src/unstable-use-fetch-frame.ts b/packages/render/src/unstable-use-fetch-frame.ts index d99c7d651..020cb9853 100644 --- a/packages/render/src/unstable-use-fetch-frame.ts +++ b/packages/render/src/unstable-use-fetch-frame.ts @@ -16,13 +16,13 @@ import { } from "./helpers"; import type { FetchFrameFunction, + FrameStackPending, + FrameStackPostPending, UseFetchFrameOptions, } from "./unstable-types"; import type { FrameGETRequest, FramePOSTRequest, - FrameStackPending, - FrameStackPostPending, SignedFrameAction, SignerStateActionContext, SignerStateInstance, @@ -43,7 +43,13 @@ function defaultErrorHandler(error: Error): void { console.error(error); } -export function useFetchFrame({ +export function useFetchFrame< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>({ frameStateAPI, frameState, frameActionProxy, @@ -67,7 +73,13 @@ export function useFetchFrame({ onTransactionProcessingError, onTransactionProcessingStart, onTransactionProcessingSuccess, -}: UseFetchFrameOptions): FetchFrameFunction { +}: UseFetchFrameOptions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>): FetchFrameFunction { async function handleFailedResponse({ response, endTime, @@ -76,7 +88,7 @@ export function useFetchFrame({ }: { endTime: Date; response: Response; - frameStackPendingItem: FrameStackPending; + frameStackPendingItem: FrameStackPending; onError?: (error: Error) => void; }): Promise { if (response.ok) { @@ -204,7 +216,8 @@ export function useFetchFrame({ pendingItem: frameStackPendingItem, endTime, parseResult, - response, + responseBody: await response.clone().text(), + response: response.clone(), }); return; @@ -227,7 +240,7 @@ export function useFetchFrame({ request: FramePOSTRequest, options?: { preflightRequest?: { - pendingFrameStackItem: FrameStackPostPending; + pendingFrameStackItem: FrameStackPostPending; startTime: Date; }; shouldClear?: boolean; @@ -235,7 +248,7 @@ export function useFetchFrame({ onSuccess?: () => void; } ): Promise { - let pendingItem: FrameStackPostPending; + let pendingItem: FrameStackPostPending; if (frameState.type === "not-initialized") { throw new Error( @@ -300,7 +313,7 @@ export function useFetchFrame({ onSuccess: onSuccessInternal, }: { response: Response; - currentPendingItem: FrameStackPostPending; + currentPendingItem: FrameStackPostPending; onError?: (error: Error) => void; onSuccess?: () => void; }): Promise { @@ -447,6 +460,7 @@ export function useFetchFrame({ parseResult: responseData, pendingItem, response, + responseBody: await response.clone().text(), }); tryCall(() => options?.onSuccess?.()); diff --git a/packages/render/src/unstable-use-frame-state.ts b/packages/render/src/unstable-use-frame-state.ts index 464a6e245..45029b0ef 100644 --- a/packages/render/src/unstable-use-frame-state.ts +++ b/packages/render/src/unstable-use-frame-state.ts @@ -1,53 +1,48 @@ import type { MutableRefObject } from "react"; import { useMemo, useReducer, useRef } from "react"; +import type { SupportedParsingSpecification } from "frames.js/frame-parsers"; +import type { FrameContext, SignerStateInstance } from "./types"; import type { - ParseFramesWithReportsResult, - SupportedParsingSpecification, -} from "frames.js/frame-parsers"; -import type { ErrorMessageResponse } from "frames.js/types"; -import type { - FrameContext, - FrameGETRequest, - FramePOSTRequest, + FrameReducerActions, FrameStackGetPending, FrameStackPostPending, - SignedFrameAction, - SignerStateActionContext, - SignerStateInstance, -} from "./types"; -import type { - FrameReducerActions, - FramesStack, + FrameState, + FrameStateAPI, ResolveSignerFunction, + UseFrameStateOptions, + UseFrameStateReturn, } from "./unstable-types"; import { useFreshRef } from "./hooks/use-fresh-ref"; -function computeDurationInSeconds(start: Date, end: Date): number { - return Number(((end.getTime() - start.getTime()) / 1000).toFixed(2)); -} - -export type FrameState = - | { - type: "initialized"; - stack: FramesStack; - signerState: SignerStateInstance; - frameContext: FrameContext; - specification: SupportedParsingSpecification; - homeframeUrl: string; - parseResult: ParseFramesWithReportsResult; - } - | { - type: "not-initialized"; - stack: FramesStack; - }; - -function createFramesStackReducer( - resolveSignerRef: MutableRefObject -) { +function createFramesStackReducer< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>(resolveSignerRef: MutableRefObject) { return function framesStackReducer( - state: FrameState, - action: FrameReducerActions - ): FrameState { + state: FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >, + action: FrameReducerActions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > + ): FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > { switch (action.action) { case "LOAD": return { @@ -56,7 +51,7 @@ function createFramesStackReducer( }; case "DONE_REDIRECT": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -76,7 +71,7 @@ function createFramesStackReducer( } case "DONE_WITH_ERROR_MESSAGE": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -95,7 +90,7 @@ function createFramesStackReducer( } case "DONE": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -135,14 +130,8 @@ function createFramesStackReducer( state.stack[index] = { ...action.pendingItem, status: "done", - speed: computeDurationInSeconds( - action.pendingItem.timestamp, - action.endTime - ), frameResult: action.parseResult[specification], - response: action.response, - responseStatus: action.response.status, - responseBody: action.parseResult, + extra: action.extra, }; return { @@ -158,7 +147,7 @@ function createFramesStackReducer( } case "REQUEST_ERROR": { const index = state.stack.findIndex( - (item) => item.timestamp === action.pendingItem.timestamp + (item) => item.id === action.pendingItem.id ); if (index === -1) { @@ -211,17 +200,10 @@ function createFramesStackReducer( url: action.homeframeUrl, }, url: action.homeframeUrl, - requestDetails: {}, - response: new Response(JSON.stringify(frameResult), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - responseStatus: 200, - timestamp: new Date(), - speed: 0, + id: Date.now(), frameResult, status: "done", - responseBody: frameResult, + extra: action.extra, }, ], }; @@ -237,85 +219,69 @@ function createFramesStackReducer( }; } -type UseFrameStateOptions = { - initialParseResult?: ParseFramesWithReportsResult | null; - initialFrameUrl?: string | null; - resolveSpecification: ResolveSignerFunction; -}; - -export type FrameStateAPI = { - dispatch: React.Dispatch; - clear: () => void; - createGetPendingItem: (arg: { - request: FrameGETRequest; - }) => FrameStackGetPending; - createPostPendingItem: < - TSignerStateActionContext extends SignerStateActionContext, - >(arg: { - action: SignedFrameAction; - request: FramePOSTRequest; - /** - * Optional, allows to override the start time - * - * @defaultValue new Date() - */ - startTime?: Date; - }) => FrameStackPostPending; - markAsDone: (arg: { - pendingItem: FrameStackGetPending | FrameStackPostPending; - endTime: Date; - response: Response; - parseResult: ParseFramesWithReportsResult; - }) => void; - markAsDoneWithRedirect: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - location: string; - response: Response; - responseBody: unknown; - }) => void; - markAsDoneWithErrorMessage: (arg: { - pendingItem: FrameStackPostPending; - endTime: Date; - response: Response; - responseData: ErrorMessageResponse; - }) => void; - markAsFailed: (arg: { - pendingItem: FrameStackGetPending | FrameStackPostPending; - endTime: Date; - requestError: Error; - response: Response | null; - responseBody: unknown; - responseStatus: number; - }) => void; - markAsFailedWithRequestError: (arg: { - endTime: Date; - pendingItem: FrameStackPostPending; - error: Error; - response: Response; - responseBody: unknown; - }) => void; - /** - * If arg is omitted it will reset the frame stack to initial frame and resolves the specification again. - * Otherwise it will set the frame state to provided values and resolve the specification. - */ - reset: (arg?: { - homeframeUrl: string; - parseResult: ParseFramesWithReportsResult; - }) => void; -}; - -export function useFrameState({ +export function useFrameState< + TExtraPending = unknown, + TExtraDone = unknown, + TExtraDoneRedirect = unknown, + TExtraRequestError = unknown, + TExtraMesssage = unknown, +>({ initialParseResult, initialFrameUrl, + initialPendingExtra, resolveSpecification, -}: UseFrameStateOptions): [FrameState, FrameStateAPI] { + resolveGETPendingExtra, + resolvePOSTPendingExtra, + resolveDoneExtra, + resolveDoneRedirectExtra, + resolveDoneWithErrorMessageExtra, + resolveFailedExtra, + resolveFailedWithRequestErrorExtra, +}: UseFrameStateOptions< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +>): UseFrameStateReturn< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage +> { + const idCounterRef = useRef(0); const resolveSpecificationRef = useFreshRef(resolveSpecification); - const reducerRef = useRef(createFramesStackReducer(resolveSpecificationRef)); + const resolveGETPendingExtraRef = useFreshRef(resolveGETPendingExtra); + const resolvePOSTPendingExtraRef = useFreshRef(resolvePOSTPendingExtra); + const resolveDoneExtraRef = useFreshRef(resolveDoneExtra); + const resolveDoneRedirectExtraRef = useFreshRef(resolveDoneRedirectExtra); + const resolveDoneWithErrorMessageExtraRef = useFreshRef( + resolveDoneWithErrorMessageExtra + ); + const resolveFailedExtraRef = useFreshRef(resolveFailedExtra); + const resolveFailedWithRequestErrorExtraRef = useFreshRef( + resolveFailedWithRequestErrorExtra + ); + const reducerRef = useRef( + createFramesStackReducer< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + >(resolveSpecificationRef) + ); const [state, dispatch] = useReducer( reducerRef.current, - [initialParseResult, initialFrameUrl] as const, - ([parseResult, frameUrl]): FrameState => { + [initialParseResult, initialFrameUrl, initialPendingExtra] as const, + ([parseResult, frameUrl, extra]): FrameState< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > => { if (parseResult && frameUrl) { const { frameContext = {}, signerState } = resolveSpecification({ parseResult, @@ -331,22 +297,15 @@ export function useFrameState({ parseResult, stack: [ { - response: new Response(JSON.stringify(frameResult), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - responseStatus: 200, - responseBody: frameResult, - timestamp: new Date(), - requestDetails: {}, + id: idCounterRef.current++, request: { method: "GET", url: frameUrl, }, - speed: 0, frameResult, status: "done", url: frameUrl, + extra: (extra ?? {}) as TExtraDone, }, ], }; @@ -358,15 +317,15 @@ export function useFrameState({ ? [ // prevent flash of empty content by adding pending item because initial frame is being loaded { + id: idCounterRef.current++, method: "GET", request: { method: "GET", url: frameUrl, }, url: frameUrl, - requestDetails: {}, - timestamp: new Date(), status: "pending", + extra: (extra ?? {}) as TExtraPending, }, ] : [], @@ -374,7 +333,13 @@ export function useFrameState({ } ); - const api: FrameStateAPI = useMemo(() => { + const api: FrameStateAPI< + TExtraPending, + TExtraDone, + TExtraDoneRedirect, + TExtraRequestError, + TExtraMesssage + > = useMemo(() => { return { dispatch, clear() { @@ -383,13 +348,14 @@ export function useFrameState({ }); }, createGetPendingItem(arg) { - const item: FrameStackGetPending = { + const item: FrameStackGetPending = { + id: idCounterRef.current++, method: "GET", request: arg.request, - requestDetails: {}, url: arg.request.url, - timestamp: new Date(), status: "pending", + extra: + resolveGETPendingExtraRef.current?.(arg) ?? ({} as TExtraPending), }; dispatch({ @@ -400,16 +366,14 @@ export function useFrameState({ return item; }, createPostPendingItem(arg) { - const item: FrameStackPostPending = { + const item: FrameStackPostPending = { + id: idCounterRef.current++, method: "POST", request: arg.request, - requestDetails: { - body: arg.action.body, - searchParams: arg.action.searchParams, - }, url: arg.action.searchParams.get("postUrl") ?? "missing postUrl", - timestamp: arg.startTime ?? new Date(), status: "pending", + extra: + resolvePOSTPendingExtraRef.current?.(arg) ?? ({} as TExtraPending), }; dispatch({ @@ -424,8 +388,7 @@ export function useFrameState({ action: "DONE", pendingItem: arg.pendingItem, parseResult: arg.parseResult, - response: arg.response.clone(), - endTime: arg.endTime, + extra: resolveDoneExtraRef.current?.(arg) ?? ({} as TExtraDone), }); }, markAsDoneWithErrorMessage(arg) { @@ -434,16 +397,12 @@ export function useFrameState({ pendingItem: arg.pendingItem, item: { ...arg.pendingItem, - responseStatus: arg.response.status, - response: arg.response.clone(), - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), status: "message", type: "error", message: arg.responseData.message, - responseBody: arg.responseData, + extra: + resolveDoneWithErrorMessageExtraRef.current?.(arg) ?? + ({} as TExtraMesssage), }, }); }, @@ -454,14 +413,10 @@ export function useFrameState({ item: { ...arg.pendingItem, location: arg.location, - response: arg.response.clone(), - responseBody: arg.responseBody, - responseStatus: arg.response.status, status: "doneRedirect", - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), + extra: + resolveDoneRedirectExtraRef.current?.(arg) ?? + ({} as TExtraDoneRedirect), }, }); }, @@ -471,18 +426,13 @@ export function useFrameState({ pendingItem: arg.pendingItem, item: { request: arg.pendingItem.request, - requestDetails: arg.pendingItem.requestDetails, - timestamp: arg.pendingItem.timestamp, + id: arg.pendingItem.id, url: arg.pendingItem.url, - response: arg.response?.clone() ?? null, - responseStatus: arg.responseStatus, requestError: arg.requestError, - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), status: "requestError", - responseBody: arg.responseBody, + extra: + resolveFailedExtraRef.current?.(arg) ?? + ({} as TExtraRequestError), }, }); }, @@ -494,13 +444,9 @@ export function useFrameState({ ...arg.pendingItem, status: "requestError", requestError: arg.error, - response: arg.response.clone(), - responseStatus: arg.response.status, - responseBody: arg.responseBody, - speed: computeDurationInSeconds( - arg.pendingItem.timestamp, - arg.endTime - ), + extra: + resolveFailedWithRequestErrorExtraRef.current?.(arg) ?? + ({} as TExtraRequestError), }, }); }, @@ -512,11 +458,21 @@ export function useFrameState({ action: "RESET_INITIAL_FRAME", homeframeUrl: arg.homeframeUrl, parseResult: arg.parseResult, + extra: (initialPendingExtra ?? {}) as TExtraDone, }); } }, }; - }, [dispatch]); + }, [ + initialPendingExtra, + resolveDoneExtraRef, + resolveDoneRedirectExtraRef, + resolveDoneWithErrorMessageExtraRef, + resolveFailedExtraRef, + resolveFailedWithRequestErrorExtraRef, + resolveGETPendingExtraRef, + resolvePOSTPendingExtraRef, + ]); return [state, api]; } diff --git a/packages/render/src/unstable-use-frame.tsx b/packages/render/src/unstable-use-frame.tsx index 013ed3b83..6dbc65cc4 100644 --- a/packages/render/src/unstable-use-frame.tsx +++ b/packages/render/src/unstable-use-frame.tsx @@ -15,6 +15,11 @@ import type { UseFrameOptions, UseFrameReturnValue } from "./unstable-types"; import { useFrameState } from "./unstable-use-frame-state"; import { useFetchFrame } from "./unstable-use-fetch-frame"; import { useFreshRef } from "./hooks/use-fresh-ref"; +import { tryCallAsync } from "./helpers"; + +function onErrorFallback(e: Error): void { + console.error("@frames.js/render", e); +} function onMintFallback({ target }: OnMintArgs): void { console.log("Please provide your own onMint function to useFrame() hook."); @@ -28,7 +33,7 @@ function onMintFallback({ target }: OnMintArgs): void { } } -function onConnectWalletFallback(): never { +function resolveAddressFallback(): never { throw new Error( "Please implement this function in order to use transactions" ); @@ -131,20 +136,33 @@ function validateLinkButtonTarget(target: string): boolean { return true; } +function defaultFetchFunction( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + return fetch(input, init); +} + export type { UseFrameReturnValue as UnstableUseFrameReturnValue, UseFrameOptions as UnstableUseFrameOptions, }; // eslint-disable-next-line camelcase -- this is only temporary -export function useFrame_unstable({ +export function useFrame_unstable< + TExtraDataPending = unknown, + TExtraDataDone = unknown, + TExtraDataDoneRedirect = unknown, + TExtraDataRequestError = unknown, + TExtraDataMesssage = unknown, +>({ + frameStateHook: useFrameStateHook = useFrameState, homeframeUrl, onMint = onMintFallback, onTransaction = onTransactionFallback, transactionDataSuffix, - onConnectWallet = onConnectWalletFallback, onSignature = onSignatureFallback, - connectedAddress, + resolveAddress = resolveAddressFallback, frame, /** Ex: /frames */ frameActionProxy, @@ -152,10 +170,10 @@ export function useFrame_unstable({ frameGetProxy, extraButtonRequestPayload, resolveSigner: resolveSpecification, - onError, + onError = onErrorFallback, onLinkButtonClick = handleLinkButtonClickFallback, onRedirect = handleRedirectFallback, - fetchFn = (...args) => fetch(...args), + fetchFn = defaultFetchFunction, onTransactionDataError, onTransactionDataStart, onTransactionDataSuccess, @@ -165,10 +183,22 @@ export function useFrame_unstable({ onTransactionProcessingSuccess, onTransactionStart, onTransactionSuccess, -}: UseFrameOptions): UseFrameReturnValue { +}: UseFrameOptions< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage +>): UseFrameReturnValue< + TExtraDataPending, + TExtraDataDone, + TExtraDataDoneRedirect, + TExtraDataRequestError, + TExtraDataMesssage +> { const [inputText, setInputText] = useState(""); const inputTextRef = useFreshRef(inputText); - const [frameState, frameStateAPI] = useFrameState({ + const [frameState, frameStateAPI] = useFrameStateHook({ resolveSpecification, initialFrameUrl: homeframeUrl, initialParseResult: frame, @@ -227,7 +257,7 @@ export function useFrame_unstable({ true ) .catch((e) => { - console.error(e); + onErrorRef.current(e instanceof Error ? e : new Error(String(e))); }); } else { resetFrameState({ @@ -235,7 +265,14 @@ export function useFrame_unstable({ parseResult: frame, }); } - }, [frame, homeframeUrl, clearFrameState, fetchFrameRef, resetFrameState]); + }, [ + frame, + homeframeUrl, + clearFrameState, + fetchFrameRef, + resetFrameState, + onErrorRef, + ]); const onPostButton = useCallback( async function onPostButton({ @@ -262,8 +299,7 @@ export function useFrame_unstable({ "Cannot perform post/post_redirect without a frame" ); - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); + onErrorRef.current(error); return; } @@ -295,8 +331,7 @@ export function useFrame_unstable({ [fetchFrameRef, frameStateRef, onErrorRef] ); - const onConnectWalletRef = useFreshRef(onConnectWallet); - const connectedAddressRef = useFreshRef(connectedAddress); + const resolveAddressRef = useFreshRef(resolveAddress); const onTransactionButton = useCallback( async function onTransactionButton({ @@ -315,8 +350,7 @@ export function useFrame_unstable({ if (currentState.type === "not-initialized") { const error = new Error("Cannot perform transaction without a frame"); - console.error(`@frames.js/render: ${error.message}`); - onErrorRef.current?.(error); + onErrorRef.current(error); return; } @@ -327,25 +361,28 @@ export function useFrame_unstable({ return; } - if (!connectedAddressRef.current) { - try { - onConnectWalletRef.current(); - } catch (e) { - onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); - console.error(`@frames.js/render: ${String(e)}`); - } + const addressOrError = await tryCallAsync(() => + resolveAddressRef.current() + ); + if (addressOrError instanceof Error) { + onErrorRef.current(addressOrError); + return; + } + + if (!addressOrError) { + onErrorRef.current( + new Error( + "Wallet address missing, please check resolveAddress() function passed to useFrame_unstable() hook." + ) + ); return; } // transaction request always requires address const frameContext = { ...currentState.frameContext, - address: - "address" in currentState.frameContext && - typeof currentState.frameContext.address === "string" - ? currentState.frameContext.address - : connectedAddressRef.current, + address: addressOrError, }; await fetchFrameRef.current({ @@ -357,7 +394,7 @@ export function useFrame_unstable({ inputText: postInputText, signer: currentState.signerState.signer, frameContext, - address: connectedAddressRef.current, + address: addressOrError, url: currentState.homeframeUrl, target: frameButton.target, frameButton, @@ -367,13 +404,7 @@ export function useFrame_unstable({ sourceFrame: currentFrame, }); }, - [ - frameStateRef, - connectedAddressRef, - fetchFrameRef, - onErrorRef, - onConnectWalletRef, - ] + [frameStateRef, fetchFrameRef, onErrorRef, resolveAddressRef] ); const onButtonPress = useCallback( @@ -389,7 +420,7 @@ export function useFrame_unstable({ validateLinkButtonTarget(frameButton.target); } catch (error) { if (error instanceof Error) { - onErrorRef.current?.(error); + onErrorRef.current(error); } return; } @@ -427,7 +458,7 @@ export function useFrame_unstable({ homeframeUrl; if (!target) { - onErrorRef.current?.(new Error(`Missing target`)); + onErrorRef.current(new Error(`Missing target`)); return; } @@ -435,7 +466,7 @@ export function useFrame_unstable({ validateLinkButtonTarget(target); } catch (error) { if (error instanceof Error) { - onErrorRef.current?.(error); + onErrorRef.current(error); } return; } @@ -460,11 +491,9 @@ export function useFrame_unstable({ }); setInputText(""); } catch (err) { - if (err instanceof Error) { - onErrorRef.current?.(err); - } - - console.error(err); + onErrorRef.current( + err instanceof Error ? err : new Error(String(err)) + ); } break; } diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts new file mode 100644 index 000000000..209a1550b --- /dev/null +++ b/packages/render/src/use-composer-action.ts @@ -0,0 +1,702 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import type { + ComposerActionFormResponse, + ComposerActionState, +} from "frames.js/types"; +import type { FarcasterSignerState } from "./farcaster"; +import { useFreshRef } from "./hooks/use-fresh-ref"; +import { + isComposerFormActionResponse, + mergeSearchParamsToUrl, + tryCall, + tryCallAsync, +} from "./helpers"; +import { + ComposerActionUnexpectedResponseError, + ComposerActionUserRejectedRequestError, +} from "./errors"; +import type { + EthSendTransactionAction, + EthSignTypedDataV4Action, + MiniAppMessage, + MiniAppResponse, +} from "./mini-app-messages"; +import { miniAppMessageSchema } from "./mini-app-messages"; +import type { FarcasterSigner } from "./identity/farcaster"; +import type { ResolveAddressFunction } from "./unstable-types"; + +export type { MiniAppMessage, MiniAppResponse, ResolveAddressFunction }; + +type FetchComposerActionFunctionArg = { + actionState: ComposerActionState; + proxyUrl: string; + signer: FarcasterSigner | null; + url: string; +}; + +type FetchComposerActionFunction = ( + arg: FetchComposerActionFunctionArg +) => Promise; + +export type RegisterMessageListener = ( + formResponse: ComposerActionFormResponse, + messageListener: MiniAppMessageListener +) => () => void; + +type MiniAppMessageListener = (message: MiniAppMessage) => Promise; + +export type OnTransactionFunctionResult = { + hash: `0x${string}`; + address: `0x${string}`; +}; + +export type OnTransactionFunction = (arg: { + action: EthSendTransactionAction; + address: `0x${string}`; +}) => Promise; + +export type OnSignatureFunctionResult = { + hash: `0x${string}`; + address: `0x${string}`; +}; + +export type OnSignatureFunction = (arg: { + action: EthSignTypedDataV4Action; + address: `0x${string}`; +}) => Promise; + +export type OnCreateCastFunction = (arg: { + cast: ComposerActionState; +}) => Promise; + +export type OnMessageRespondFunction = ( + response: MiniAppResponse, + form: ComposerActionFormResponse +) => unknown; + +export type UseComposerActionOptions = { + /** + * Current action state, value should be memoized. It doesn't cause composer action / mini app to refetch. + */ + actionState: ComposerActionState; + /** + * URL to composer action / mini app server + * + * If value changes it will refetch the composer action / mini app + */ + url: string; + /** + * Signer used to sign the composer action. + * + * If value changes it will refetch the composer action / mini app + */ + signer: FarcasterSignerState; + /** + * URL to the action proxy server. If value changes composer action / mini app will be refetched. + * + * Proxy must handle POST requests. + */ + proxyUrl: string; + /** + * If enabled if will fetch the composer action / mini app on mount. + * + * @defaultValue true + */ + enabled?: boolean; + /** + * Custom fetch function to fetch the composer action / mini app. + */ + fetch?: (url: string, init: RequestInit) => Promise; + /** + * Called before onTransaction/onSignature is invoked to obtain an address to use. + * + * If the function returns null onTransaction/onSignature will not be called. + */ + resolveAddress: ResolveAddressFunction; + onError?: (error: Error) => void; + onCreateCast: OnCreateCastFunction; + onTransaction: OnTransactionFunction; + onSignature: OnSignatureFunction; + /** + * Called with message response to be posted to child (e.g. iframe). + */ + onMessageRespond: OnMessageRespondFunction; + /** + * Allows to override how the message listener is registered. Function must return a function that removes the listener. + * + * Changes in the value aren't reflected so it's recommended to use a memoized function. + * + * By default it uses window.addEventListener("message", ...) + */ + registerMessageListener?: RegisterMessageListener; +}; + +type UseComposerActionResult = { + refetch: () => Promise; +} & ( + | { + status: "idle"; + data: undefined; + error: undefined; + } + | { + status: "loading"; + data: undefined; + error: undefined; + } + | { + status: "error"; + data: undefined; + error: Error; + } + | { + status: "success"; + data: ComposerActionFormResponse; + error: undefined; + } +); + +export function useComposerAction({ + actionState, + enabled = true, + proxyUrl, + signer, + url, + fetch: fetchFunction, + onError, + onCreateCast, + onSignature, + onTransaction, + resolveAddress, + registerMessageListener = defaultRegisterMessageListener, + onMessageRespond, +}: UseComposerActionOptions): UseComposerActionResult { + const onErrorRef = useFreshRef(onError); + const [state, dispatch] = useReducer(composerActionReducer, { + status: "idle", + enabled, + }); + const registerMessageListenerRef = useFreshRef(registerMessageListener); + const actionStateRef = useFreshRef(actionState); + const onCreateCastRef = useFreshRef(onCreateCast); + const onMessageRespondRef = useFreshRef(onMessageRespond); + const onTransactionRef = useFreshRef(onTransaction); + const onSignatureRef = useFreshRef(onSignature); + const resolveAddressRef = useFreshRef(resolveAddress); + const lastFetchActionArgRef = useRef( + null + ); + const signerRef = useFreshRef(signer); + const fetchRef = useFreshRef(fetchFunction); + + const messageListener = useCallback( + async ( + successState: Extract, + message: MiniAppMessage + ) => { + if ("type" in message || message.method === "fc_createCast") { + const cast = + "type" in message ? message.data.cast : message.params.cast; + + const resultOrError = await tryCallAsync(() => + onCreateCastRef.current({ + cast, + }) + ); + + if (resultOrError instanceof Error) { + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + error: { + code: -32000, + message: resultOrError.message, + }, + }, + successState.response + ); + } + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + result: { + success: true, + }, + }, + successState.response + ); + } else if (message.method === "fc_requestWalletAction") { + const addressOrError = await tryCallAsync(() => + resolveAddressRef.current() + ); + + if (addressOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(addressOrError)); + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: addressOrError.message, + }, + }, + successState.response + ); + + return; + } + + if (!addressOrError) { + return; + } + + if (message.params.action.method === "eth_sendTransaction") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onTransactionRef.current({ + action, + address: addressOrError, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }, + successState.response + ); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }, + successState.response + ); + return; + } + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + transactionHash: resultOrError.hash, + }, + }, + successState.response + ); + } else if (message.params.action.method === "eth_signTypedData_v4") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onSignatureRef.current({ + action, + address: addressOrError, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }, + successState.response + ); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }, + successState.response + ); + + return; + } + + onMessageRespondRef.current( + { + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + signature: resultOrError.hash, + }, + }, + successState.response + ); + } else { + tryCall(() => + onErrorRef.current?.( + new Error( + `Unknown fc_requestWalletAction action method: ${message.params.action.method}` + ) + ) + ); + } + } else { + tryCall(() => onErrorRef.current?.(new Error("Unknown message"))); + } + }, + [ + onCreateCastRef, + onErrorRef, + onMessageRespondRef, + onSignatureRef, + onTransactionRef, + resolveAddressRef, + ] + ); + + const fetchAction = useCallback( + async (arg) => { + const currentSigner = arg.signer; + + if ( + currentSigner?.status !== "approved" && + currentSigner?.status !== "impersonating" + ) { + await signerRef.current.onSignerlessFramePress(); + return; + } + + dispatch({ type: "loading-url" }); + + const signedDataOrError = await tryCallAsync(() => + signerRef.current.signComposerAction(currentSigner.privateKey, { + url: arg.url, + state: arg.actionState, + fid: currentSigner.fid, + }) + ); + + if (signedDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(signedDataOrError)); + dispatch({ type: "error", error: signedDataOrError }); + + return; + } + + const proxiedUrl = mergeSearchParamsToUrl( + arg.proxyUrl, + new URLSearchParams({ postUrl: arg.url }) + ); + + const actionResponseOrError = await tryCallAsync(() => + (fetchRef.current || fetch)(proxiedUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(signedDataOrError), + cache: "no-cache", + }) + ); + + if (actionResponseOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseOrError)); + dispatch({ type: "error", error: actionResponseOrError }); + + return; + } + + if (!actionResponseOrError.ok) { + const error = new Error( + `Unexpected response status ${actionResponseOrError.status}` + ); + + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + const actionResponseDataOrError = await tryCallAsync( + () => actionResponseOrError.clone().json() as Promise + ); + + if (actionResponseDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseDataOrError)); + dispatch({ type: "error", error: actionResponseDataOrError }); + + return; + } + + if (!isComposerFormActionResponse(actionResponseDataOrError)) { + const error = new ComposerActionUnexpectedResponseError(); + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + dispatch({ type: "done", response: actionResponseDataOrError }); + }, + [fetchRef, onErrorRef, signerRef] + ); + + const stateRef = useFreshRef(state); + const refetch = useCallback(() => { + if (!stateRef.current.enabled || !lastFetchActionArgRef.current) { + return Promise.resolve(); + } + + return fetchAction(lastFetchActionArgRef.current); + }, [fetchAction, stateRef]); + + useEffect(() => { + dispatch({ type: "enabled-change", enabled }); + }, [enabled]); + + useEffect(() => { + if (!enabled) { + return; + } + + lastFetchActionArgRef.current = { + actionState: actionStateRef.current, + signer: signer.signer as unknown as FarcasterSigner | null, + url, + proxyUrl, + }; + + fetchAction(lastFetchActionArgRef.current).catch((e) => { + onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); + }); + }, [ + url, + proxyUrl, + signer.signer, + enabled, + fetchAction, + actionStateRef, + onErrorRef, + ]); + + // register message listener when state changes to success + useEffect(() => { + if (state.status === "success") { + return registerMessageListenerRef.current( + state.response, + messageListener.bind(null, state) + ); + } + }, [messageListener, registerMessageListenerRef, state]); + + return useMemo(() => { + switch (state.status) { + case "idle": + return { + status: "idle", + data: undefined, + error: undefined, + refetch, + }; + case "loading": + return { + status: "loading", + data: undefined, + error: undefined, + refetch, + }; + case "error": + return { + status: "error", + data: undefined, + error: state.error, + refetch, + }; + default: + return { + status: "success", + data: state.response, + error: undefined, + refetch, + }; + } + }, [state, refetch]); +} + +export { miniAppMessageSchema }; + +/** + * Default function used to register message listener. Works in browsers only. + */ +export const defaultRegisterMessageListener: RegisterMessageListener = + function defaultRegisterMessageListener(formResponse, messageListener) { + if (typeof window === "undefined") { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: You are using default registerMessageListener in an environment without window object" + ); + + return () => { + // noop + }; + } + + const miniAppOrigin = new URL(formResponse.url).origin; + + const messageParserListener = (event: MessageEvent): void => { + // make sure that we only listen to messages from the mini app + if (event.origin !== miniAppOrigin) { + return; + } + + const result = miniAppMessageSchema.safeParse(event.data); + + if (!result.success) { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: Invalid message received", + event.data, + result.error + ); + return; + } + + const message = result.data; + + messageListener(message).catch((e) => { + // eslint-disable-next-line no-console -- provide feedback + console.error(`@frames.js/render:`, e); + }); + }; + + window.addEventListener("message", messageParserListener); + + return () => { + window.removeEventListener("message", messageParserListener); + }; + }; + +type SharedComposerActionReducerState = { + enabled: boolean; +}; + +type ComposerActionReducerState = SharedComposerActionReducerState & + ( + | { + status: "idle"; + } + | { + status: "loading"; + } + | { + status: "error"; + error: Error; + } + | { + status: "success"; + response: ComposerActionFormResponse; + } + ); + +type ComposerActionReducerAction = + | { + type: "loading-url"; + } + | { + type: "error"; + error: Error; + } + | { + type: "done"; + response: ComposerActionFormResponse; + } + | { + type: "enabled-change"; + enabled: boolean; + }; + +function composerActionReducer( + state: ComposerActionReducerState, + action: ComposerActionReducerAction +): ComposerActionReducerState { + if (action.type === "enabled-change") { + if (action.enabled) { + return { + ...state, + enabled: true, + }; + } + + return { + status: "idle", + enabled: false, + }; + } + + if (!state.enabled) { + return state; + } + + switch (action.type) { + case "done": + return { + ...state, + status: "success", + response: action.response, + }; + case "loading-url": + return { + ...state, + status: "loading", + }; + case "error": + return { + ...state, + status: "error", + error: action.error, + }; + default: + return state; + } +} diff --git a/packages/render/src/use-frame-stack.ts b/packages/render/src/use-frame-stack.ts index 2c49ae757..8dda97991 100644 --- a/packages/render/src/use-frame-stack.ts +++ b/packages/render/src/use-frame-stack.ts @@ -1,4 +1,4 @@ -import { useMemo, useReducer } from "react"; +import { type MutableRefObject, useMemo, useReducer, useRef } from "react"; import type { Frame, SupportedParsingSpecification } from "frames.js"; import type { ParseResult } from "frames.js/frame-parsers"; import type { @@ -23,6 +23,7 @@ function computeDurationInSeconds(start: Date, end: Date): number { } function framesStackReducer( + idCounterRef: MutableRefObject, state: FramesStack, action: FrameReducerActions ): FramesStack { @@ -83,6 +84,7 @@ function framesStackReducer( return [ { + id: idCounterRef.current++, request: { method: "GET", url: action.homeframeUrl ?? "", @@ -204,8 +206,9 @@ export function useFrameStack({ React.Dispatch, FrameStackAPI, ] { + const idCounterRef = useRef(0); const [stack, dispatch] = useReducer( - framesStackReducer, + framesStackReducer.bind(null, idCounterRef), [initialFrame, initialFrameUrl, initialSpecification] as const, ([frame, frameUrl, specification]): FramesStack => { if (frame) { @@ -219,6 +222,7 @@ export function useFrameStack({ }; return [ { + id: idCounterRef.current++, response: new Response(JSON.stringify(frameResult), { status: 200, headers: { "Content-Type": "application/json" }, @@ -242,6 +246,7 @@ export function useFrameStack({ // this is then handled by fetchFrame having second argument set to true so the stack is cleared return [ { + id: idCounterRef.current++, method: "GET", request: { method: "GET", @@ -268,6 +273,7 @@ export function useFrameStack({ }, createGetPendingItem(arg) { const item: FrameStackGetPending = { + id: idCounterRef.current++, method: "GET", request: arg.request, requestDetails: {}, @@ -285,6 +291,7 @@ export function useFrameStack({ }, createPostPendingItem(arg) { const item: FrameStackPostPending = { + id: idCounterRef.current++, method: "POST", request: arg.request, requestDetails: { @@ -305,6 +312,7 @@ export function useFrameStack({ }, createCastOrComposerActionPendingItem(arg) { return { + id: idCounterRef.current++, method: "POST", requestDetails: { body: arg.action.body, @@ -405,6 +413,7 @@ export function useFrameStack({ action: "REQUEST_ERROR", pendingItem: arg.pendingItem, item: { + id: arg.pendingItem.id, request: arg.pendingItem.request, requestDetails: arg.pendingItem.requestDetails, timestamp: arg.pendingItem.timestamp,