import { detect } from 'detect-browser' import { Hono } from 'hono' import { ImageResponse, type ImageResponseOptions } from 'hono-og' import type { HonoOptions } from 'hono/hono-base' import { html } from 'hono/html' import type { Schema } from 'hono/types' import lz from 'lz-string' // TODO: maybe write our own "modern" universal path (or resolve) module. // We are not using `node:path` to remain compatible with Edge runtimes. import { default as p } from 'path-browserify' import type { Env } from './types/env.js' import type { FrameImageAspectRatio, FrameResponse } from './types/frame.js' import type { Hub } from './types/hub.js' import type { FrameHandler, HandlerInterface, MiddlewareHandlerInterface, TransactionHandler, } from './types/routes.js' import { fromQuery } from './utils/fromQuery.js' import { getButtonValues } from './utils/getButtonValues.js' import { getFrameContext } from './utils/getFrameContext.js' import { getRouteParameters } from './utils/getRouteParameters.js' import { getTransactionContext } from './utils/getTransactionContext.js' import * as jws from './utils/jws.js' import { parseBrowserLocation } from './utils/parseBrowserLocation.js' import { parseImage } from './utils/parseImage.js' import { parseIntents } from './utils/parseIntents.js' import { parsePath } from './utils/parsePath.js' import { requestBodyToContext } from './utils/requestBodyToContext.js' import { serializeJson } from './utils/serializeJson.js' import { toSearchParams } from './utils/toSearchParams.js' import { version } from './version.js' export type FrogConstructorParameters< env extends Env = Env, basePath extends string = '/', // _state = env['State'], > = Pick & { /** * The base path for assets. * * @example '/' (commonly for Vercel Serverless Functions) */ assetsPath?: basePath | string | undefined /** * The base path for the server instance. * * @example '/api' (commonly for Vercel Serverless Functions) */ basePath?: basePath | string | undefined /** * Options for built-in devtools. */ dev?: | { /** * Enables built-in devtools * * @default true */ enabled?: boolean | undefined /** Custom app fid to auth with. */ appFid?: number | undefined /** Custom app mnemonic to auth with. */ appMnemonic?: string | undefined } | undefined /** * HTTP response headers. */ headers?: Record | undefined /** * Options to forward to the `Hono` instance. */ honoOptions?: HonoOptions | undefined /** * @deprecated Use `hub` instead. * * Farcaster Hub API URL. */ hubApiUrl?: string | undefined /** * Farcaster Hub API configuration. */ hub?: Hub | undefined /** * Default image options. * * @see https://vercel.com/docs/functions/og-image-generation/og-image-api * @see https://vercel.com/docs/functions/og-image-generation/og-image-examples#using-a-custom-font * * @example * { width: 1200, height: 630 } * * @example * async () => { * const fontData = await fetch( * new URL('./assets/inter.ttf', import.meta.url), * ).then((res) => res.arrayBuffer()); * * return { fonts: [{ name: 'Inter', data: fontData, style: 'normal'}] } * } */ imageOptions?: | ImageResponseOptions | (() => Promise) | undefined /** * Default image aspect ratio. * * @default '1.91:1' */ imageAspectRatio?: FrameImageAspectRatio | undefined /** * Initial state for the frames. * * @example * ```ts * initialState: { * index: 0, * todos: [], * } * ``` */ initialState?: _state | undefined /** * Key used to sign secret data. * * It is used for: * - Signing frame state, and * - Signing auth session cookies in the devtools. * * It's strongly recommended to add a strong secret key before deploying to production. * * @example * '1zN3Uvl5QQd7OprLfp83IU96W6ip6KNPQ+l0MECPIZh8FBLYKQ+mPXE1CTxfwXGz' */ secret?: string | undefined /** * Whether or not to verify frame data via the Farcaster Hub's `validateMessage` API. * * - When `true`, the frame will go through verification and throw an error if it fails. * - When `"silent"`, the frame will go through verification, but not throw an error if it fails. * Instead, the frame will receive `verified: false` in its context. * - When `false`, the frame will not go through verification. * * @default true. */ verify?: boolean | 'silent' | undefined } export type RouteOptions = Pick & { fonts?: | ImageResponseOptions['fonts'] | (() => Promise) } /** * A Frog instance. * * @param parameters - {@link FrogConstructorParameters} * @returns instance. {@link Frog} * * @example * ``` * import { Frog } from 'frog' * * const app = new Frog() * * app.frame('/', (c) => { * const { buttonValue, inputText, status } = c * const fruit = inputText || buttonValue * return c.res({ * image: ( *
* {fruit ? `You selected: ${fruit}` : 'Welcome!'} *
* ), * intents: [ * , * , * , * ] * }) * }) * ``` */ export class FrogBase< env extends Env = Env, schema extends Schema = {}, basePath extends string = '/', // _state = env['State'], > { // Note: not using native `private` fields to avoid tslib being injected // into bundled code. _initialState: env['State'] = undefined as env['State'] /** Path for assets. */ assetsPath: string /** Base path of the server instance. */ basePath: string /** URL to redirect to when the user is coming to the page via a browser. */ browserLocation: string | undefined dev: FrogConstructorParameters['dev'] | undefined headers: FrogConstructorParameters['headers'] | undefined /** Hono instance. */ hono: Hono /** Farcaster Hub API URL. */ hubApiUrl: string | undefined /** Farcaster Hub API config. */ hub: Hub | undefined /** Image aspect ratio. */ imageAspectRatio: FrameImageAspectRatio = '1.91:1' /** Image options. */ imageOptions: | ImageResponseOptions | (() => Promise) | undefined fetch: Hono['fetch'] get: Hono['get'] post: Hono['post'] /** Key used to sign secret data. */ secret: FrogConstructorParameters['secret'] | undefined /** Whether or not frames should be verified. */ verify: FrogConstructorParameters['verify'] = true constructor({ assetsPath, basePath, browserLocation, dev, headers, honoOptions, hubApiUrl, hub, imageAspectRatio, imageOptions, initialState, secret, verify, }: FrogConstructorParameters = {}) { this.hono = new Hono(honoOptions) if (basePath) this.hono = this.hono.basePath(basePath) if (browserLocation) this.browserLocation = browserLocation if (headers) this.headers = headers if (dev) this.dev = { enabled: true, ...(dev ?? {}) } if (hubApiUrl) this.hubApiUrl = hubApiUrl if (hub) this.hub = hub if (imageAspectRatio) this.imageAspectRatio = imageAspectRatio if (imageOptions) this.imageOptions = imageOptions if (secret) this.secret = secret if (typeof verify !== 'undefined') this.verify = verify this.basePath = basePath ?? '/' this.assetsPath = assetsPath ?? this.basePath this.fetch = this.hono.fetch.bind(this.hono) this.get = this.hono.get.bind(this.hono) this.post = this.hono.post.bind(this.hono) if (initialState) this._initialState = initialState } frame: HandlerInterface = ( ...parameters: any[] ) => { const [path, middlewares, handler, options = {}] = getRouteParameters< env, FrameHandler >(...parameters) const { verify = this.verify } = options // Frame Route (implements GET & POST). this.hono.use(parsePath(path), ...middlewares, async (c) => { const url = new URL(c.req.url) const assetsUrl = url.origin + parsePath(this.assetsPath) const baseUrl = url.origin + parsePath(this.basePath) const { context, getState } = getFrameContext({ context: await requestBodyToContext(c, { hub: this.hub || (this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined), secret: this.secret, verify, }), initialState: this._initialState, }) if (context.url !== parsePath(c.req.url)) return c.redirect(context.url) const response = await handler(context) if (response instanceof Response) return response const { action, browserLocation = this.browserLocation, headers = this.headers, imageAspectRatio = this.imageAspectRatio, image, imageOptions, intents, ogImage, title = 'Frog Frame', } = response.data const buttonValues = getButtonValues(parseIntents(intents)) if (context.status === 'redirect' && context.buttonIndex) { const buttonValue = buttonValues[context.buttonIndex - 1] const location = buttonValue?.replace(/^_r:/, '') if (!location) throw new Error('location required to redirect') return c.redirect(location, 302) } // If the user is coming from a browser, and a `browserLocation` is set, // then we will redirect the user to that location. const browser = detect(c.req.header('user-agent')) const browserLocation_ = parseBrowserLocation(c, browserLocation, { basePath: this.basePath, path, }) if (browser?.name && browserLocation_) return c.redirect( browserLocation_.startsWith('http') ? browserLocation_ : `${url.origin + p.resolve(this.basePath, browserLocation_)}`, 302, ) // Derive the previous state, and sign it if a secret is provided. const previousState = await (async () => { const state = await context.deriveState() if (!this.secret) return state if (!state) return state return jws.sign(JSON.stringify(state), this.secret) })() // We need to pass some context to the next frame to derive button values, state, etc. // Here, we are deriving two sets of "next frame state". // 1. For the INITIAL FRAME, we need to pass through the state as a search parameter // due to Farcaster's constraints with the `fc:frame:state` tag. It must be empty // for the initial frame. // 2. For SUBSEQUENT FRAMES, we can pass through the state as a serialized JSON object // to the next frame via the `fc:frame:state` tag. const nextFrameStateSearch = toSearchParams({ initialPath: context.initialPath, previousButtonValues: buttonValues, }) const nextFrameStateMeta = serializeJson({ initialPath: context.initialPath, previousButtonValues: buttonValues, previousState, }) const imageUrl = await (async () => { if (typeof image !== 'string') { const encodedImage = lz.compressToEncodedURIComponent( JSON.stringify(await parseImage(image, { assetsUrl })), ) const imageParams = toSearchParams({ image: encodedImage, imageOptions: imageOptions ? { ...imageOptions, // TODO: Remove once `fonts` is removed from `imageOptions`. fonts: undefined, } : undefined, headers, }) return `${parsePath(context.url)}/image?${imageParams}` } if (image.startsWith('http') || image.startsWith('data')) return image return `${assetsUrl + parsePath(image)}` })() const ogImageUrl = (() => { if (!ogImage) return undefined if (ogImage.startsWith('http')) return ogImage return baseUrl + parsePath(ogImage) })() const postUrl = (() => { if (!action) return context.url if (action.startsWith('http')) return action return baseUrl + parsePath(action) })() const parsedIntents = parseIntents(intents, { baseUrl, search: context.status === 'initial' ? nextFrameStateSearch.toString() : undefined, }) // Set response headers provided by consumer. for (const [key, value] of Object.entries(headers ?? {})) c.header(key, value) const isDevEnabled = // check if devtools are enabled on constructor. (this.dev?.enabled ?? true) && // check if route has `/dev` path. this.hono.routes.some((r) => { const currentFullPath = (this.basePath === '/' ? '' : this.basePath) + parsePath(path) if (!r.path.startsWith(currentFullPath)) return false if (!r.path.includes('/dev')) return false return true }) // The devtools needs a serialized context. const serializedContext = serializeJson({ ...context, // note: unserializable entities are undefined. env: context.env ? Object.assign(context.env, { incoming: undefined, outgoing: undefined, }) : undefined, req: undefined, state: getState(), }) const body = isDevEnabled ? ( open 𝒇𝒓𝒂𝒎𝒆 devtools ) : ( ) return c.render( <> {html``} {context.status !== 'initial' && ( )} {parsedIntents} {isDevEnabled && ( )} {body} , ) }) // OG Image Route this.hono.get(`${parsePath(path)}/image`, async (c) => { const defaultImageOptions = await (async () => { if (typeof this.imageOptions === 'function') return await this.imageOptions() return this.imageOptions })() const fonts = await (async () => { if (typeof options?.fonts === 'function') return await options.fonts() if (options?.fonts) return options.fonts return defaultImageOptions?.fonts })() const { headers = this.headers, image, imageOptions = defaultImageOptions, } = fromQuery(c.req.query()) const image_ = JSON.parse(lz.decompressFromEncodedURIComponent(image)) return new ImageResponse(image_, { ...imageOptions, fonts, headers: imageOptions?.headers ?? headers, }) }) return this } route< subPath extends string, subSchema extends Schema, subBasePath extends string, >(path: subPath, frog: FrogBase) { if (frog.assetsPath === '/') frog.assetsPath = this.assetsPath if (frog.basePath === '/') frog.basePath = parsePath(this.basePath) + parsePath(path) if (!frog.browserLocation) frog.browserLocation = this.browserLocation if (!frog.dev) frog.dev = this.dev if (!frog.headers) frog.headers = this.headers if (!frog.hubApiUrl) frog.hubApiUrl = this.hubApiUrl if (!frog.hub) frog.hub = this.hub if (!frog.imageOptions) frog.imageOptions = this.imageOptions if (!frog.secret) frog.secret = this.secret if (!frog.verify) frog.verify = this.verify this.hono.route(path, frog.hono) return this } transaction: HandlerInterface = ( ...parameters: any[] ) => { const [path, middlewares, handler, options = {}] = getRouteParameters< env, TransactionHandler >(...parameters) const { verify = this.verify } = options this.hono.post(parsePath(path), ...middlewares, async (c) => { const { context } = getTransactionContext({ context: await requestBodyToContext(c, { hub: this.hub || (this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined), secret: this.secret, verify, }), req: c.req, }) const response = await handler(context) if (response instanceof Response) return response return c.json(response.data) }) return this } use: MiddlewareHandlerInterface = (...args: any[]) => { this.hono.use(...args) return this as any } }