Skip to content

Commit

Permalink
nit: wip
Browse files Browse the repository at this point in the history
  • Loading branch information
dalechyn committed Dec 9, 2024
1 parent c8a86d3 commit e55cc6e
Show file tree
Hide file tree
Showing 11 changed files with 568 additions and 16 deletions.
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 @@ -13,6 +13,7 @@ import { app as neynarApp } from './neynar.js'
import { app as routingApp } from './routing.js'
import { app as signatureApp } from './signature.js'
import { app as todoApp } from './todos.js'
import { app as frameV2App } from './frameV2.js'
import { app as transactionApp } from './transaction.js'
import { app as uiSystemApp } from './ui-system.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 Down Expand Up @@ -59,6 +60,8 @@ import { requestBodyToImageContext } from './utils/requestBodyToImageContext.js'
import { serializeJson } from './utils/serializeJson.js'
import { toSearchParams } from './utils/toSearchParams.js'
import { version } from './version.js'
import { requestBodyToFrameV2Context } from './utils/requestBodyToFrameV2Context.js'
import { getFrameV2Context } from './utils/getFrameV2Context.js'

export type FrogConstructorParameters<
env extends Env = Env,
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

0 comments on commit e55cc6e

Please sign in to comment.