From ad7cc0186237671dde207c332a5160e02beb9fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Sat, 9 Mar 2024 19:22:42 +0100 Subject: [PATCH] feat: new simple framework agnostic api --- .../framesjs-starter/app/examples/page.tsx | 5 + .../simplified-api/[[...routes]]/route.tsx | 72 +++++ .../app/examples/simplified-api/new-api.tsx | 286 ++++++++++++++++++ .../app/examples/simplified-api/react-api.tsx | 168 ++++++++++ examples/framesjs-starter/package.json | 3 +- 5 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx create mode 100644 examples/framesjs-starter/app/examples/simplified-api/new-api.tsx create mode 100644 examples/framesjs-starter/app/examples/simplified-api/react-api.tsx diff --git a/examples/framesjs-starter/app/examples/page.tsx b/examples/framesjs-starter/app/examples/page.tsx index 63bb4ab9f..0201ab7f7 100644 --- a/examples/framesjs-starter/app/examples/page.tsx +++ b/examples/framesjs-starter/app/examples/page.tsx @@ -34,6 +34,11 @@ export default function ExamplesIndexPage() { Multi protocol +
  • + + Simplified api + +
  • Slow request diff --git a/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx b/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx new file mode 100644 index 000000000..1c96d25e2 --- /dev/null +++ b/examples/framesjs-starter/app/examples/simplified-api/[[...routes]]/route.tsx @@ -0,0 +1,72 @@ +import { AnyJson } from "../new-api"; +import { + FrameImage, + Frame, + FramesApp, + FrameLink, + renderFramesToResponse, +} from "../react-api"; + +type FrameState = { + clicked: number; +}; + +function isFrameState( + frameState: AnyJson | undefined +): frameState is FrameState { + if (frameState && typeof frameState === "object" && "clcked" in frameState) { + return typeof frameState.clicked === "number"; + } + + return false; +} + +export const framesApp = ( + + + {({ frameState }) => ( + <> + +
    Test
    +
    + + + )} + + + {({ frameState }) => ( + <> + +
    + Click # + {isFrameState(frameState) + ? frameState.clicked + : "Not clicked yet"} +
    +
    + + + )} + +
    +); + +/** + * GET renders always only initial frames + */ +export async function GET(req: Request) { + return renderFramesToResponse(framesApp, req); +} + +/** + * POST always render subsequent frames in reaction to buttons + */ +export async function POST(req: Request) { + return renderFramesToResponse(framesApp, req); +} diff --git a/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx b/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx new file mode 100644 index 000000000..11c18d6e3 --- /dev/null +++ b/examples/framesjs-starter/app/examples/simplified-api/new-api.tsx @@ -0,0 +1,286 @@ +import assert from "assert"; +import { ImageResponse } from "@vercel/og"; +import { ImageResponseOptions } from "next/server"; +import { getFrameMessage } from "frames.js/next/server"; +import { FrameActionDataParsedAndHubContext } from "frames.js"; + +/** A subset of JS objects that are serializable */ +export type AnyJson = boolean | number | string | null | JsonArray | JsonMap; +export interface JsonMap { + [key: string]: AnyJson; +} +interface JsonArray extends Array {} + +interface IFrameImage { + renderToMetaTag(): Promise; +} + +export class Frame { + private image: IFrameImage | undefined; + private buttons: IFrameButton[] = []; + /** + * This is frame state coming from Button no the global app state comming from FramesApp + */ + private state: AnyJson | undefined = undefined; + + getState() { + return this.state; + } + + setState(state: AnyJson | undefined) { + this.state = state; + } + + registerImage( + definitionOrURL: React.ReactElement | string | URL + ): IFrameImage { + if ( + typeof definitionOrURL === "object" && + !(definitionOrURL instanceof URL) + ) { + return (this.image = new FrameImage(definitionOrURL)); + } + + return (this.image = new FrameImageURL(new URL(definitionOrURL))); + } + + registerPostButton(path: `/${string}`, label: string): FramePostButton { + if (this.buttons.length >= 4) { + throw new Error("Maximum number of buttons is 4"); + } + + const button = new FramePostButton( + path, + (this.buttons.length + 1) as AllowedButtonIndexes, + label + ); + + this.buttons.push(button); + + return button; + } + + registerExternalLinkButton(href: URL, label: string): FrameExternalButton { + if (this.buttons.length >= 4) { + throw new Error("Maximum number of buttons is 4"); + } + + const button = new FrameExternalButton( + href, + (this.buttons.length + 1) as AllowedButtonIndexes, + label + ); + + this.buttons.push(button); + + return button; + } + + async toHTML(app: FramesApp): Promise { + assert(this.image, "Frame image is not set"); + + return ` + + + + + ${await this.image.renderToMetaTag()} + + + + + `.trim(); + } +} + +type AllowedButtonIndexes = 1 | 2 | 3 | 4; + +interface IFrameButton { + renderToMetaTag(): string; +} + +class FrameExternalButton implements IFrameButton { + private href: URL | undefined; + private index: AllowedButtonIndexes | undefined; + private label: string | undefined; + + constructor(href: URL, index: AllowedButtonIndexes, label: string) { + this.href = href; + this.index = index; + this.label = label; + } + + renderToMetaTag() { + assert(this.href, "Button href is not set"); + assert(this.index, "Button index is not set"); + assert(this.label, "Button label is not set"); + + return ` + + + + `.trim(); + } +} + +class FramePostButton implements IFrameButton { + private path: `/${string}` | undefined; + private index: AllowedButtonIndexes | undefined; + private label: string | undefined; + + constructor(path: `/${string}`, index: AllowedButtonIndexes, label: string) { + this.path = path; + this.index = index; + this.label = label; + } + + renderToMetaTag(): string { + assert(this.path, "Button path is not set"); + assert(this.index, "Button index is not set"); + assert(this.label, "Button label is not set"); + + return ` + + + + `.trim(); + } +} + +export class FrameImageURL implements IFrameImage { + private url: URL; + + constructor(url: URL) { + this.url = url; + } + + async renderToMetaTag(): Promise { + return ` + + + `.trim(); + } +} + +export class FrameImage implements IFrameImage { + private definition: React.ReactElement; + private options: ImageResponseOptions | undefined; + + constructor(definition: React.ReactElement, options?: ImageResponseOptions) { + this.definition = definition; + this.options = options; + } + + async renderToMetaTag(): Promise { + const imageResponse = new ImageResponse( + ( +
    +
    + {this.definition} +
    +
    + ), + this.options + ); + const base64 = Buffer.from(await imageResponse.arrayBuffer()).toString( + "base64" + ); + + return ` + + + `.trim(); + } +} + +export class FramesApp { + private state: JsonMap = {}; + private frames: Record<`/${string}`, Frame> = {}; + private frameMessage: FrameActionDataParsedAndHubContext | null = null; + + setInitialState(state: JsonMap) { + this.state = state; + return this; + } + + getState() { + return this.state; + } + + getFrameMessage() { + return this.frameMessage; + } + + registerFrame(path: `/${string}`) { + if (this.frames[path]) { + throw new Error(`Frame with path ${path} already exists`); + } + + return (this.frames[path] = new Frame()); + } + + async renderToResponse(req: Request) { + const path = new URL(req.url).pathname as `/${string}`; + + // @TODO if the request method is POST try to decode the frame message if any + // and set the state to detected state from message + + // this is just naive implementation to show the idea + if (!this.frames[path]) { + return new Response("Not Found", { status: 404 }); + } + + // extract frame message if POST request with JSON body + if (req.method === "POST") { + try { + // set as property, this is cleared after request + this.frameMessage = await getFrameMessage(await req.json()); + } catch (e) { + return new Response("Internal Server Error", { status: 500 }); + } + } + + try { + const frame = this.frames[path]!; + + try { + // extract frameState from searchParams and pass it to frame + const frameState = new URL(req.url).searchParams.get("_fs"); + frame.setState(frameState ? JSON.parse(frameState) : undefined); + } catch {} + + const html = await frame.toHTML(this); + + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + } catch (e) { + return new Response("Internal Server Error", { status: 500 }); + } finally { + this.frameMessage = null; + } + } +} diff --git a/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx b/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx new file mode 100644 index 000000000..8c00fb6c3 --- /dev/null +++ b/examples/framesjs-starter/app/examples/simplified-api/react-api.tsx @@ -0,0 +1,168 @@ +import { createContext, useContext } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import * as newApi from "./new-api"; +import { FrameActionDataParsedAndHubContext } from "frames.js"; + +type FrameImageProps = { + children: React.ReactElement; +}; + +/** + * This component throws a promise so we don't need to use server components and it is possible to use it in any app (Next, Remix, Express) + * with our renderFrames function. + */ +export function FrameImage({ children }: FrameImageProps) { + const frame = useContext(FrameContext); + + if (!frame) { + throw new Error("FrameImage must be used inside a Frame"); + } + + frame.registerImage(children); + + return null; +} + +type FrameLinkProps = { + path: `/${string}`; + label: string; + /** + * State send to next frame in case this button is clicked + */ + state?: newApi.AnyJson; +}; + +export function FrameLink({ path, label }: FrameLinkProps) { + const frame = useContext(FrameContext); + + if (!frame) { + throw new Error("FrameLink must be used inside a Frame"); + } + + frame.registerPostButton(path, label); + + return null; +} + +type FramePostRedirectButtonProps = {}; + +export function FramePostRedirectButton(props: FramePostRedirectButtonProps) { + return <>; +} + +type FrameExternalLinkProps = {}; + +export function FrameExternalLink(props: FrameExternalLinkProps) { + return <>; +} + +type FrameMintButtonProps = {}; + +export function FrameMintButton(props: FrameMintButtonProps) { + return <>; +} + +type FramesAppProps = { + /** + * @default {} + */ + initialState?: newApi.JsonMap; + children: React.ReactElement | React.ReactElement[]; +}; + +export function FramesApp({ initialState = {} }: FramesAppProps) { + const app = useContext(FramesAppContext); + + app.setInitialState(initialState); + + return <>; +} + +const FrameContext = createContext(null as any); + +type AllowedFrameChildren = + | React.ReactComponentElement + | React.ReactComponentElement + | React.ReactComponentElement + | React.ReactComponentElement + | React.ReactComponentElement; + +type FrameRenderFunctionParams = { + app: newApi.FramesApp; + /** + * If undefined there was no state detected (either no button has been clicked or button is not setting the state) + */ + frameState: newApi.AnyJson | undefined; + /** + * Frame message from current request (set only if POST request and is containing the message) + */ + frameMessage: FrameActionDataParsedAndHubContext | null; +}; +type FrameRenderFunction = ( + params: FrameRenderFunctionParams +) => AllowedFrameChildren | AllowedFrameChildren[]; + +type FrameProps = + | { + index: true; + children: + | AllowedFrameChildren + | AllowedFrameChildren[] + | FrameRenderFunction; + } + | { + path: `/${string}`; + children: + | AllowedFrameChildren + | AllowedFrameChildren[] + | FrameRenderFunction; + }; + +export function Frame(props: FrameProps) { + const app = useContext(FramesAppContext); + const parentFrame = useContext(FrameContext); + + if (parentFrame) { + throw new Error("Frame cannot be nested"); + } + + let frame: newApi.Frame; + + if ("index" in props) { + frame = app.registerFrame("/"); + } else { + frame = app.registerFrame(props.path); + } + + return ( + + {typeof props.children === "function" + ? props.children({ + app, + frameState: frame.getState(), + frameMessage: app.getFrameMessage(), + }) + : props.children} + + ); +} + +const FramesAppContext = createContext({} as any); + +export async function renderFramesToResponse( + app: React.ReactComponentElement, + req: Request +): Promise { + const _app = new newApi.FramesApp(); + + try { + renderToStaticMarkup( + {app} + ); + + return _app.renderToResponse(req); + } catch (e) { + console.error(e); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/examples/framesjs-starter/package.json b/examples/framesjs-starter/package.json index 347cdb7b4..e49895393 100644 --- a/examples/framesjs-starter/package.json +++ b/examples/framesjs-starter/package.json @@ -16,6 +16,7 @@ "@farcaster/core": "^0.14.3", "@noble/ed25519": "^2.0.0", "@vercel/kv": "^1.0.1", + "@vercel/og": "^0.6.2", "@xmtp/frames-validator": "^0.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -43,4 +44,4 @@ "tailwindcss": "^3.3.0", "typescript": "^5.3.3" } -} \ No newline at end of file +}