Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.

feat: frames v2 #530

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions playground/src/frameV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Frog } from 'frog'

export const app = new Frog({ verify: 'silent', title: 'Signature' }).frameV2(
'/',
(c) => {
return c.res({
action: {
name: 'My App',
url: 'https://google.com',
splashImageUrl: 'https://google.com',
splashBackgroundColor: '#000',
},
buttonTitle: 'Button!',
image: 'https://yoink.party/img/start.png',
})
},
)
2 changes: 2 additions & 0 deletions playground/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Box, Heading, vars } from './ui.js'
import { app as castActionApp } from './castAction.js'
import { app as composerActionApp } from './composerAction.js'
import { app as fontsApp } from './fonts.js'
import { app as frameV2App } from './frameV2.js'
import { app as initial } from './initial.js'
import { app as middlewareApp } from './middleware.js'
import { app as neynarApp } from './neynar.js'
Expand Down Expand Up @@ -204,6 +205,7 @@ export const app = new Frog({
.route('/transaction', transactionApp)
.route('/todos', todoApp)
.route('/signature', signatureApp)
.route('/frame-v2', frameV2App)
.frame('/:dynamic', (c) => {
const dynamic = c.req.param('dynamic')
return c.res({
Expand Down
177 changes: 177 additions & 0 deletions src/frog-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
CastActionHandler,
ComposerActionHandler,
FrameHandler,
FrameV2Handler,
H,
HandlerInterface,
ImageHandler,
Expand All @@ -39,6 +40,7 @@ import { getButtonValues } from './utils/getButtonValues.js'
import { getCastActionContext } from './utils/getCastActionContext.js'
import { getComposerActionContext } from './utils/getComposerActionContext.js'
import { getFrameContext } from './utils/getFrameContext.js'
import { getFrameV2Context } from './utils/getFrameV2Context.js'
import { getImageContext } from './utils/getImageContext.js'
import { getImagePaths } from './utils/getImagePaths.js'
import { getRequestUrl } from './utils/getRequestUrl.js'
Expand All @@ -55,6 +57,7 @@ import { parsePath } from './utils/parsePath.js'
import { requestBodyToCastActionBaseContext } from './utils/requestBodyToCastActionBaseContext.js'
import { requestBodyToComposerActionBaseContext } from './utils/requestBodyToComposerActionBaseContext.js'
import { requestBodyToFrameBaseContext } from './utils/requestBodyToFrameBaseContext.js'
import { requestBodyToFrameV2Context } from './utils/requestBodyToFrameV2Context.js'
import { requestBodyToImageContext } from './utils/requestBodyToImageContext.js'
import { serializeJson } from './utils/serializeJson.js'
import { toSearchParams } from './utils/toSearchParams.js'
Expand Down Expand Up @@ -1022,6 +1025,180 @@ export class FrogBase<
return this
}

frameV2: HandlerInterface<env, 'frame-v2', schema, basePath> = (
...parameters: any[]
) => {
const [path, middlewares, handler] = getRouteParameters<
env,
FrameV2Handler<env>,
'frame-v2'
>(...parameters)
// Frame V2 Route (implements GET).
this.hono.get(parseHonoPath(path), ...middlewares, async (c) => {
const url = getRequestUrl(c.req)
const origin = this.origin ?? url.origin
const assetsUrl = origin + parsePath(this.assetsPath)
const baseUrl = origin + parsePath(this.basePath)

const { context } = await getFrameV2Context({
context: await requestBodyToFrameV2Context(c, {
secret: this.secret,
}),
contextHono: c,
initialState: this._initialState,
})

const response = await handler(context)
if (response instanceof Response) return response
if (response.status === 'error') {
c.status(response.error.statusCode ?? 400)
return c.json({ message: response.error.message })
}

const {
action,
buttonTitle,
headers = this.headers,
image,
ogTitle,
ogImage,
} = response.data

const imageUrl = await (async () => {
if (image.startsWith('http') || image.startsWith('data')) return image

const isHandlerPresentOnImagePath = (() => {
const routes = inspectRoutes(this.hono)
const matchesWithoutParamsStash = this.hono.router
.match(
'GET',
// `this.initialBasePath` and `this.basePath` are equal only when this handler is triggered at
// the top `Frog` instance.
//
// However, such are not equal when an instance of `Frog` is routed to another one via `.route`,
// and since we not expect one to set `basePath` to the instance which is being routed to, we can
// safely assume it's only set at the top level, as doing otherwise is irrational.
//
// Since `this.basePath` is set at the top instance, we have to account for that while looking for a match.
//
// @ts-ignore - accessing a private field
this.initialBasePath === this.basePath
? this.basePath + parsePath(image)
: parsePath(image),
)
.filter(
(routeOrParams) => typeof routeOrParams[0] !== 'string',
) as unknown as (
| [[H, RouterRoute], Params][]
| [[H, RouterRoute], ParamIndexMap][]
)[]

const matchedRoutes = matchesWithoutParamsStash
.flat(1)
.map((matchedRouteWithoutParams) => matchedRouteWithoutParams[0][1])

const nonMiddlewareMatchedRoutes = matchedRoutes.filter(
(matchedRoute) => {
const routeWithAdditionalInfo = routes.find(
(route) =>
route.path === matchedRoute.path &&
route.method === matchedRoute.method,
)
if (!routeWithAdditionalInfo)
throw new Error(
'Unexpected error: Matched a route that is not in the list of all routes.',
)
return !routeWithAdditionalInfo.isMiddleware
},
)
return nonMiddlewareMatchedRoutes.length !== 0
})()

if (isHandlerPresentOnImagePath) return `${baseUrl + parsePath(image)}`
return `${assetsUrl + parsePath(image)}`
})()

const ogImageUrl = (() => {
if (!ogImage) return undefined
if (ogImage.startsWith('http')) return ogImage
return baseUrl + parsePath(ogImage)
})()

// Set response headers provided by consumer.
for (const [key, value] of Object.entries(headers ?? {}))
c.header(key, value)

const metaTagsMap = new Map<string, string>()
for (const tag of [
...(response.data.unstable_metaTags ?? []),
...(this.metaTags ?? []),
]) {
if (metaTagsMap.has(tag.property)) continue
metaTagsMap.set(tag.property, tag.content)
}
const metaTags =
metaTagsMap.size === 0
? []
: Array.from(metaTagsMap).map((x) => ({
property: x[0],
content: x[1],
}))

return c.render(
<>
{html`<!DOCTYPE html>`}
<html lang="en">
<head>
<meta
property="fc:frame"
content={JSON.stringify({
version: 'next',
imageUrl,
button: {
title: buttonTitle,
action: {
type: 'launch_frame',
...action,
},
},
})}
/>
<meta property="og:image" content={ogImageUrl ?? imageUrl} />
<meta property="og:title" content={ogTitle} />

<meta property="frog:version" content={version} />
{/* The devtools needs a serialized context. */}
{/* {c.req.header('x-frog-dev') !== undefined && ( */}
{/* <meta */}
{/* property="frog:context" */}
{/* content={serializeJson({ */}
{/* ...context, */}
{/* // note: unserializable entities are undefined. */}
{/* env: context.env */}
{/* ? Object.assign(context.env, { */}
{/* incoming: undefined, */}
{/* outgoing: undefined, */}
{/* }) */}
{/* : undefined, */}
{/* req: undefined, */}
{/* state: getState(), */}
{/* })} */}
{/* /> */}
{/* )} */}

{metaTags.map((tag) => (
<meta property={tag.property} content={tag.content} />
))}
</head>
<body />
</html>
</>,
)
})

return this
}

image: HandlerInterface<env, 'image', schema, basePath> = (
...parameters: any[]
) => {
Expand Down
45 changes: 45 additions & 0 deletions src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from './composerAction.js'
import type { Env } from './env.js'
import type { FrameButtonValue, FrameData, FrameResponseFn } from './frame.js'
import type { FrameV2ResponseFn } from './frameV2.js'
import type { ImageResponseFn } from './image.js'
import type { BaseErrorResponseFn } from './response.js'
import type {
Expand Down Expand Up @@ -270,6 +271,50 @@ export type FrameContext<
transactionId?: FrameData['transactionId'] | undefined
}

export type FrameV2Context<
env extends Env = Env,
path extends string = string,
input extends Input = {},
//
_state = env['State'],
> = {
/**
* `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers.
*
* @example
* ```ts
* // Environment object for Cloudflare Workers
* app.frame('/', async c => {
* const counter = c.env.COUNTER
* })
* ```
* @see https://hono.dev/api/context#env
*/
env: Context_hono<env, path>['env']
/**
* Button values from the previous frame.
*/
previousButtonValues?: FrameButtonValue[] | undefined
/**
* State from the previous frame.
*/
previousState: _state
/**
* Hono request object.
*
* @see https://hono.dev/api/context#req
*/
req: Context_hono<env, path, input>['req']
/** Frame response that includes frame properties such as: image, action, etc */
res: FrameV2ResponseFn
/**
* Extract a context value that was previously set via `set` in [Middleware](/concepts/middleware).
*
* @see https://hono.dev/api/context#var
*/
var: Context_hono<env, path, input>['var']
}

export type TransactionContext<
env extends Env = Env,
path extends string = string,
Expand Down
71 changes: 71 additions & 0 deletions src/types/frameV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { TypedResponse } from './response.js'

export type FrameV2Response = {
/**
* Frame action options.
*/
action: {
/**
* App name. Required.
*
* @example "Yoink!"
*/
name: string
/**
* Default launch URL. Required
*
* @example "https://yoink.party/"
*/
url: string
/**
* 200x200px splash image URL. Must be less than 1MB.
*
* @example "https://yoink.party/img/splash.png"
*/
splashImageUrl: string
/**
* App Splash Image Background Color.
*
* @example '#000'
*/
splashBackgroundColor: `#${string}`
}
/**
* Title of the button.
*
* @example 'Yoink!'
*/
buttonTitle: string
/**
* HTTP response headers.
*/
headers?: Record<string, string> | undefined
/**
* Path or URI to the OG image.
*
* @default The `image` property.
*/
ogImage?: string | undefined
/**
* Title of the frame (added as `og:title`).
*
* @example 'Hello Frog'
*/
ogTitle?: string | undefined
/**
* Additional meta tags for the frame.
*/
unstable_metaTags?: { property: string; content: string }[] | undefined
/**
* Frame image. Must be 3:2 aspect ratio. Must be less than 10 MB.
*
*
* @example "https://yoink.party/img/start.png"
* @example "/image-1"
*/
image: string
}

export type FrameV2ResponseFn = (
response: FrameV2Response,
) => TypedResponse<FrameV2Response>
1 change: 1 addition & 0 deletions src/types/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type TypedResponse<data> = {
| 'castAction'
| 'composerAction'
| 'frame'
| 'frame-v2'
| 'transaction'
| 'image'
| 'signature'
Expand Down
Loading
Loading