diff --git a/playground/src/frameV2.tsx b/playground/src/frameV2.tsx new file mode 100644 index 00000000..81615ff3 --- /dev/null +++ b/playground/src/frameV2.tsx @@ -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', + }) + }, +) diff --git a/playground/src/index.tsx b/playground/src/index.tsx index 85c146d2..f8267814 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -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' @@ -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({ diff --git a/src/frog-base.tsx b/src/frog-base.tsx index 18302953..c282c078 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -25,6 +25,7 @@ import type { CastActionHandler, ComposerActionHandler, FrameHandler, + FrameV2Handler, H, HandlerInterface, ImageHandler, @@ -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, @@ -1022,6 +1025,180 @@ export class FrogBase< return this } + frameV2: HandlerInterface = ( + ...parameters: any[] + ) => { + const [path, middlewares, handler] = getRouteParameters< + env, + FrameV2Handler, + '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() + 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``} + + + + + + + + {/* The devtools needs a serialized context. */} + {/* {c.req.header('x-frog-dev') !== undefined && ( */} + {/* */} + {/* )} */} + + {metaTags.map((tag) => ( + + ))} + + + + , + ) + }) + + return this + } + image: HandlerInterface = ( ...parameters: any[] ) => { diff --git a/src/types/context.ts b/src/types/context.ts index 78c3fd81..d2a288a5 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -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 { @@ -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'] + /** + * 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['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['var'] +} + export type TransactionContext< env extends Env = Env, path extends string = string, diff --git a/src/types/frameV2.ts b/src/types/frameV2.ts new file mode 100644 index 00000000..890eaf2f --- /dev/null +++ b/src/types/frameV2.ts @@ -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 | 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 diff --git a/src/types/response.ts b/src/types/response.ts index 1ea5f88c..1bab2258 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -10,6 +10,7 @@ export type TypedResponse = { | 'castAction' | 'composerAction' | 'frame' + | 'frame-v2' | 'transaction' | 'image' | 'signature' diff --git a/src/types/routes.ts b/src/types/routes.ts index e88ba21c..b0588bd5 100644 --- a/src/types/routes.ts +++ b/src/types/routes.ts @@ -15,6 +15,7 @@ import type { ComposerActionContext, FrameBaseContext, FrameContext, + FrameV2Context, ImageContext, SignatureContext, TransactionContext, @@ -25,6 +26,7 @@ import type { ImageResponse } from './image.js' import type { HandlerResponse } from './response.js' import type { SignatureResponse } from './signature.js' import type { TransactionResponse } from './transaction.js' +import type { FrameV2Response } from './frameV2.js' //////////////////////////////////////// ////// ////// @@ -68,6 +70,12 @@ export type FrameHandler< I extends Input = BlankInput, > = (c: FrameContext) => HandlerResponse +export type FrameV2Handler< + E extends Env = any, + P extends string = any, + I extends Input = BlankInput, +> = (c: FrameV2Context) => HandlerResponse + export type ImageHandler< E extends Env = any, P extends string = any, @@ -108,17 +116,19 @@ export type H< M extends string = string, > = M extends 'frame' ? FrameHandler - : M extends 'transaction' - ? TransactionHandler - : M extends 'castAction' - ? CastActionHandler - : M extends 'composerAction' - ? ComposerActionHandler - : M extends 'image' - ? ImageHandler - : M extends 'signature' - ? SignatureHandler - : Handler + : M extends 'frame-v2' + ? FrameV2Handler + : M extends 'transaction' + ? TransactionHandler + : M extends 'castAction' + ? CastActionHandler + : M extends 'composerAction' + ? ComposerActionHandler + : M extends 'image' + ? ImageHandler + : M extends 'signature' + ? SignatureHandler + : Handler //////////////////////////////////////// ////// ////// diff --git a/src/utils/getFrameV2Context.ts b/src/utils/getFrameV2Context.ts new file mode 100644 index 00000000..9c680d15 --- /dev/null +++ b/src/utils/getFrameV2Context.ts @@ -0,0 +1,56 @@ +import type { Context as Context_Hono, Input } from 'hono' +import type { FrameV2Context } from '../types/context.js' +import type { Env } from '../types/env.js' + +type GetFrameV2ContextParameters< + env extends Env = Env, + path extends string = string, + input extends Input = {}, + state = env['State'], +> = { + context: Pick< + FrameV2Context, + 'env' | 'previousState' | 'previousButtonValues' | 'req' | 'var' + > + contextHono: Context_Hono + initialState?: + | ((c: FrameV2Context) => state | Promise) + | state + | undefined +} + +type GetFrameV2ContextReturnType< + env extends Env = Env, + path extends string = string, + input extends Input = {}, + state = env['State'], +> = Promise<{ + context: FrameV2Context +}> + +export async function getFrameV2Context< + env extends Env, + path extends string, + input extends Input = {}, + state = env['State'], +>( + parameters: GetFrameV2ContextParameters, +): GetFrameV2ContextReturnType { + const { context, contextHono } = parameters + const { env, previousState, req } = context || {} + return { + context: { + env, + previousState: await (async () => { + if (previousState) return previousState + + if (typeof parameters.initialState === 'function') + return await (parameters.initialState as any)(contextHono) + return parameters.initialState + })(), + req, + res: (data) => ({ data, format: 'frame', status: 'success' }), + var: context.var, + }, + } +} diff --git a/src/utils/getImageContext.ts b/src/utils/getImageContext.ts index a326bd66..e185ac65 100644 --- a/src/utils/getImageContext.ts +++ b/src/utils/getImageContext.ts @@ -6,17 +6,16 @@ type GetImageContextParameters< env extends Env = Env, path extends string = string, input extends Input = {}, - // - _state = env['State'], + state = env['State'], > = { context: Omit< - FrameBaseContext, + FrameBaseContext, 'frameData' | 'verified' | 'status' | 'initialPath' > contextHono: Context_Hono initialState?: - | ((c: FrameBaseContext) => _state | Promise<_state>) - | _state + | ((c: FrameBaseContext) => state | Promise) + | state | undefined } diff --git a/src/utils/requestBodyToFrameV2Context.ts b/src/utils/requestBodyToFrameV2Context.ts new file mode 100644 index 00000000..40ff184b --- /dev/null +++ b/src/utils/requestBodyToFrameV2Context.ts @@ -0,0 +1,56 @@ +import type { Context as Context_hono, Input } from 'hono' +import type { FrogConstructorParameters } from '../frog-base.js' +import type { FrameBaseContext } from '../types/context.js' +import type { Env } from '../types/env.js' +import { fromQuery } from './fromQuery.js' +import { getRequestUrl } from './getRequestUrl.js' +import * as jws from './jws.js' + +type RequestBodyToFrameV2ContextOptions = { + secret?: FrogConstructorParameters['secret'] +} + +type RequestBodyToFrameV2ContextReturnType< + env extends Env = Env, + path extends string = string, + input extends Input = {}, + // + _state = env['State'], +> = Omit< + FrameBaseContext, + 'frameData' | 'verified' | 'status' | 'initialPath' +> + +export async function requestBodyToFrameV2Context< + env extends Env, + path extends string, + input extends Input, + // + _state = env['State'], +>( + c: Context_hono, + { secret }: RequestBodyToFrameV2ContextOptions, +): Promise> { + const { previousState, previousButtonValues } = await (async () => { + if (c.req.query()) { + let { previousState, previousButtonValues } = fromQuery( + c.req.query(), + ) as any + if (secret && previousState) + previousState = JSON.parse(await jws.verify(previousState, secret)) + return { previousState, previousButtonValues } + } + return {} as any + })() + + const url = getRequestUrl(c.req) + + return { + env: c.env, + previousState, + previousButtonValues, + req: c.req, + url: url.href, + var: c.var, + } +} diff --git a/src/web/frames/buildFarcasterManifest.ts b/src/web/frames/buildFarcasterManifest.ts new file mode 100644 index 00000000..de39c7c9 --- /dev/null +++ b/src/web/frames/buildFarcasterManifest.ts @@ -0,0 +1,118 @@ +export type TriggerConfig = { + /** + * Type of trigger, either cast or composer. Required. + */ + type: 'cast' | 'composer' + + /** + * Unique ID. Required. Reported to the frame. + * @example 'yoink-score' + */ + id: string + + /** + * Handler URL. Required. + * @example 'https://yoink.party/triggers/cast' + */ + url: string + + /** + * Name override. Optional, defaults to {@link FrameConfig`name`} + * @example 'View Yoink Score' + */ + name?: string | undefined +} +export type FrameConfig = { + /** + * Manifest version. Required. + * + * @example "0.0.0" + */ + version: string + + /** + * App name. Required. + * + * @example "Yoink!" + */ + name: string + + /** + * Default launch URL. Required + * + * @example "https://yoink.party/" + */ + homeUrl: string + + /** + * 200x200px frame application icon URL. Must be less than 1MB. + * + * @example "https://yoink.party/img/icon.png" + */ + iconUrl: string + + /** + * 200x200px splash image URL. Must be less than 1MB. + * + * @example "https://yoink.party/img/splash.png" + */ + splashImageUrl?: string | undefined + + /** + * Hex color code. + * + * @example "#eeeee4" + */ + splashBackgroundColor?: string | undefined + + /** + * URL to which clients will POST events. + * Required if the frame application uses notifications. + * + * @example "https://yoink.party/webhook" + */ + webhookUrl?: string | undefined +} +export type FarcasterManifest = { + /** + * Metadata associating the domain with a Farcaster account + */ + accountAssociation: { + /** + * base64url encoded JFS header. + * See FIP: JSON Farcaster Signatures for details on this format. + */ + header: string + /** + * base64url encoded payload containing a single property `domain` + */ + payload: string + + /** + * base64url encoded signature bytes + */ + signature: string + } + + /** + * Frame configuration + */ + frame: FrameConfig + + /** + * Trigger Configuration + */ + triggers?: TriggerConfig[] | undefined +} + +/** + * This method is basicaly a no-op but good to have in case if + * folks haven't heard of `satisfies` keyword. + * + * You can simply use `{} satisfies FarcasterManifest` to achieve the same outcome. + */ +export function buildFarcasterManifest( + manifest: FarcasterManifest, +): FarcasterManifest { + return manifest +}