`
+
+HTTP response headers to set for the image.
+
+```tsx twoslash
+// @noErrors
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+export const app = new Frog()
+
+app.image('/image/foo', (c) => {
+ return c.res({
+ headers: { // [!code focus]
+ 'cache-control': 'max-age=0', // [!code focus]
+ }, // [!code focus]
+ image: (
+
+ Select a frog
+
+ )
+ })
+})
+```
+
+## image
+
+- **Type:** `string`
+
+The Image to render for the image. Must be a JSX element.
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+export const app = new Frog()
+
+app.image('/', (c) => {
+ return c.res({
+ image: ( // [!code focus]
+ // [!code focus]
+ Select a frog // [!code focus]
+
// [!code focus]
+ ) // [!code focus]
+ })
+})
+```
+
+## imageOptions
+
+- **Type:** `ImageResponseOptions`
+
+[Options for the image](https://vercel.com/docs/functions/og-image-generation/og-image-api) to render for the image.
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+export const app = new Frog()
+
+app.image('/', (c) => {
+ return c.res({
+ image: (
+
+ Select a frog
+
+ ),
+ imageOptions: { // [!code focus]
+ height: 600, // [!code focus]
+ width: 600, // [!code focus]
+ } // [!code focus]
+ })
+})
+```
+
+:::warning
+The `fonts` property is not available on the `imageOptions` passed to `c.res`. If you would like to add custom fonts, either:
+
+1. Add it to `imageOptions.fonts` on the `new Frog(){:js}` instance:
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Frog } from 'frog'
+
+export const app = new Frog({
+ imageOptions: { fonts: [/* ... */] } // [!code focus]
+})
+```
+
+2. Add it to the route options:
+
+```tsx twoslash
+// @noErrors
+/** @jsxImportSource frog/jsx */
+import { Frog } from 'frog'
+
+export const app = new Frog({
+ imageOptions: { fonts: [/* ... */] }
+})
+// ---cut---
+app.image('/', (c) => {
+ return c.res({})
+}, {
+ fonts: [/* ... */] // [!code focus]
+})
+```
+
+:::
+
diff --git a/site/pages/reference/frog-image.mdx b/site/pages/reference/frog-image.mdx
new file mode 100644
index 00000000..91ccf907
--- /dev/null
+++ b/site/pages/reference/frog-image.mdx
@@ -0,0 +1,93 @@
+# Frog.image
+
+## Import
+
+```tsx twoslash
+import { Frog } from 'frog'
+```
+
+## Usage
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+const app = new Frog()
+
+app.image('/', (c) => { // [!code focus]
+ return c.res({ // [!code focus]
+ image: ( // [!code focus]
+ // [!code focus]
+ Select your fruit! // [!code focus]
+
// [!code focus]
+ ) // [!code focus]
+ }) // [!code focus]
+}) // [!code focus]
+```
+
+## Parameters
+
+### path
+
+- **Type:** `string`
+
+Path of the route.
+
+[Read more.](/concepts/routing)
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+const app = new Frog()
+
+app.image(
+ '/foo/bar', // [!code focus]
+ (c) => {
+ return c.res({
+ image: (
+
+ Select your fruit!
+
+ )
+ })
+ }
+)
+```
+
+### handler
+
+- **Type:** `(c: Context) => ImageResponse`
+
+Handler function for the route.
+
+```tsx twoslash
+/** @jsxImportSource frog/jsx */
+// ---cut---
+import { Button, Frog } from 'frog'
+
+const app = new Frog()
+
+app.image(
+ '/foo/bar',
+ (c) => { // [!code focus]
+ return c.res({ // [!code focus]
+ image: ( // [!code focus]
+ // [!code focus]
+ Select your fruit! // [!code focus]
+
// [!code focus]
+ )
+ }) // [!code focus]
+ } // [!code focus]
+)
+```
+
+#### Context Parameter
+
+[See the Context API.](/reference/frog-image-context)
+
+#### Image Response
+
+[See the Image Response API.](/reference/frog-image-response)
diff --git a/site/vocs.config.tsx b/site/vocs.config.tsx
index bb9a63dc..3b2fb4e7 100644
--- a/site/vocs.config.tsx
+++ b/site/vocs.config.tsx
@@ -3,8 +3,7 @@ import { version } from '../src/package.json'
import { getFrameMetadata } from '../src/utils/getFrameMetadata.js'
export default defineConfig({
- banner:
- '👣 Introducing [Multi-step Cast Actions](/concepts/multi-step-cast-actions).',
+ banner: '🖼️Introducing [Image Handler](/concepts/image-handler).',
description: 'Framework for Farcaster Frames',
iconUrl: '/icon.png',
async head({ path }) {
@@ -273,6 +272,10 @@ export default defineConfig({
text: 'Images & Intents',
link: '/concepts/images-intents',
},
+ {
+ text: 'Image Handler',
+ link: '/concepts/image-handler',
+ },
{
text: 'Connecting Frames (Actions)',
link: '/concepts/actions',
@@ -395,6 +398,17 @@ export default defineConfig({
},
],
},
+ {
+ text: 'Frog.image',
+ link: '/reference/frog-image',
+ items: [
+ { text: 'Context', link: '/reference/frog-image-context' },
+ {
+ text: 'Response',
+ link: '/reference/frog-image-response',
+ },
+ ],
+ },
{
text: 'Frog.transaction',
link: '/reference/frog-transaction',
diff --git a/src/frog-base.tsx b/src/frog-base.tsx
index 05d9a777..14c1254b 100644
--- a/src/frog-base.tsx
+++ b/src/frog-base.tsx
@@ -1,9 +1,11 @@
import { detect } from 'detect-browser'
import { Hono } from 'hono'
import { ImageResponse } from 'hono-og'
+import { inspectRoutes } from 'hono/dev'
import type { HonoOptions } from 'hono/hono-base'
import { html } from 'hono/html'
-import type { Schema } from 'hono/types'
+import type { ParamIndexMap, Params } from 'hono/router'
+import type { RouterRoute, 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.
@@ -20,7 +22,9 @@ import type { Octicon } from './types/octicon.js'
import type {
CastActionHandler,
FrameHandler,
+ H,
HandlerInterface,
+ ImageHandler,
MiddlewareHandlerInterface,
TransactionHandler,
} from './types/routes.js'
@@ -29,6 +33,7 @@ import { fromQuery } from './utils/fromQuery.js'
import { getButtonValues } from './utils/getButtonValues.js'
import { getCastActionContext } from './utils/getCastActionContext.js'
import { getFrameContext } from './utils/getFrameContext.js'
+import { getImageContext } from './utils/getImageContext.js'
import { getImagePaths } from './utils/getImagePaths.js'
import { getRequestUrl } from './utils/getRequestUrl.js'
import { getRouteParameters } from './utils/getRouteParameters.js'
@@ -178,15 +183,39 @@ export type RouteOptions = Pick<
FrogConstructorParameters,
'verify'
> &
- (method extends 'frame'
+ (method extends 'frame' | 'image'
? {
fonts?: ImageOptions['fonts'] | (() => Promise)
}
: method extends 'castAction'
? {
+ /**
+ * An action name up to 30 characters.
+ *
+ * @example `'My action.'`
+ */
name: string
+ /**
+ * An icon ID.
+ *
+ * @see https://warpcast.notion.site/Spec-Farcaster-Actions-84d5a85d479a43139ea883f6823d8caa
+ * @example `'log'`
+ */
icon: Octicon
+ /**
+ * A short description up to 80 characters.
+ *
+ * @example `'My awesome action description.'`
+ */
description?: string
+ /**
+ * Optional external link to an "about" page.
+ * You should only include this if you can't fully describe your
+ * action using the `description` field.
+ * Must be http or https protocol.
+ *
+ * @example `'My awesome action description.'`
+ */
aboutUrl?: string
}
: {})
@@ -586,6 +615,40 @@ export class FrogBase<
return `${parsePath(context.url)}/image?${imageParams}`
}
if (image.startsWith('http') || image.startsWith('data')) return image
+
+ const isHandlerPresentOnImagePath = (() => {
+ const routes = inspectRoutes(this.hono)
+ const matchesWithoutParamsStash = this.hono.router
+ .match('GET', 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)}`
})()
@@ -782,6 +845,64 @@ export class FrogBase<
return this
}
+ image: HandlerInterface = (
+ ...parameters: any[]
+ ) => {
+ const [path, middlewares, handler, options = {}] = getRouteParameters<
+ env,
+ ImageHandler,
+ 'image'
+ >(...parameters)
+
+ if (path.endsWith('/image'))
+ throw new Error(
+ 'Image handler path cannot end with `/image` as it might conflict with internal frame image handler path that also ends with `/image`.',
+ )
+
+ this.hono.get(path, ...middlewares, async (c) => {
+ const { context } = getImageContext({
+ context: c,
+ })
+
+ const response = await handler(context)
+
+ if (response.status !== 'success')
+ throw new Error(
+ `Unexepcted Error: Image response must always have value 'success'.`,
+ )
+
+ const defaultImageOptions = await (async () => {
+ if (typeof this.imageOptions === 'function')
+ return await this.imageOptions()
+ return this.imageOptions
+ })()
+
+ const fonts = await (async () => {
+ if (this.ui?.vars?.fonts)
+ return Object.values(this.ui?.vars.fonts).flat()
+ 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,
+ } = response.data
+ return new ImageResponse(image, {
+ width: 1200,
+ height: 630,
+ ...imageOptions,
+ format: imageOptions?.format ?? 'png',
+ fonts: await parseFonts(fonts),
+ headers: imageOptions?.headers ?? headers,
+ })
+ })
+
+ return this
+ }
+
route<
subPath extends string,
subSchema extends Schema,
diff --git a/src/types/context.ts b/src/types/context.ts
index 646db98b..227741a4 100644
--- a/src/types/context.ts
+++ b/src/types/context.ts
@@ -7,6 +7,7 @@ import type {
} from './castAction.js'
import type { Env } from './env.js'
import type { FrameButtonValue, FrameData, FrameResponseFn } from './frame.js'
+import type { ImageResponseFn } from './image.js'
import type { BaseErrorResponseFn } from './response.js'
import type {
ContractTransactionResponseFn,
@@ -230,3 +231,39 @@ export type TransactionContext<
*/
send: TransactionResponseFn
}
+
+export type ImageContext<
+ env extends Env = Env,
+ path extends string = string,
+ input extends Input = {},
+> = {
+ /**
+ * `.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.castAction('/', async c => {
+ * const counter = c.env.COUNTER
+ * })
+ * ```
+ * @see https://hono.dev/api/context#env
+ */
+ env: Context_hono['env']
+ /**
+ * Hono request object.
+ *
+ * @see https://hono.dev/api/context#req
+ */
+ req: Context_hono['req']
+ /**
+ * Raw response with the image and imageOptions.
+ * */
+ res: ImageResponseFn
+ /**
+ * 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']
+}
diff --git a/src/types/frame.ts b/src/types/frame.ts
index 506dcbb7..65be8827 100644
--- a/src/types/frame.ts
+++ b/src/types/frame.ts
@@ -77,31 +77,12 @@ export type FrameResponse = {
* HTTP response headers.
*/
headers?: Record | undefined
- /**
- * The OG Image to render for the frame. Can either be a JSX element, or URL.
- *
- * @example
- * Hello Frog
- *
- * @example
- * "https://i.ytimg.com/vi/R3UACX5eULI/maxresdefault.jpg"
- */
- image: string | JSX.Element
/**
* The aspect ratio of the OG Image.
*
* @example '1:1'
*/
imageAspectRatio?: FrameImageAspectRatio | undefined
- /**
- * Image options.
- *
- * @see https://vercel.com/docs/functions/og-image-generation/og-image-api
- *
- * @example
- * { width: 1200, height: 630 }
- */
- imageOptions?: ImageOptions | undefined
/**
* Path or URI to the OG image.
*
@@ -129,7 +110,36 @@ export type FrameResponse = {
* Additional meta tags for the frame.
*/
unstable_metaTags?: { property: string; content: string }[] | undefined
-}
+} & (
+ | {
+ /**
+ * The OG Image to render for the frame. Can either be a JSX element, or URL.
+ *
+ * @example
+ * Hello Frog
+ */
+ image: JSX.Element
+ /**
+ * Image options.
+ *
+ * @see https://vercel.com/docs/functions/og-image-generation/og-image-api
+ *
+ * @example
+ * { width: 1200, height: 630 }
+ */
+ imageOptions?: ImageOptions | undefined
+ }
+ | {
+ /**
+ * The OG Image to render for the frame. Can either be a JSX element, or URL.
+ *
+ * @example
+ * "https://i.ytimg.com/vi/R3UACX5eULI/maxresdefault.jpg"
+ */
+ image: string
+ imageOptions?: never
+ }
+)
export type FrameResponseFn = (
response: FrameResponse,
diff --git a/src/types/image.ts b/src/types/image.ts
new file mode 100644
index 00000000..4aa766fa
--- /dev/null
+++ b/src/types/image.ts
@@ -0,0 +1,30 @@
+import type { ImageOptions } from './frame.js'
+import type { TypedResponse } from './response.js'
+
+export type ImageResponse = {
+ /**
+ * HTTP response headers.
+ */
+ headers?: Record | undefined
+ /**
+ * The OG Image to render for the frame.
+ *
+ * @example
+ * Hello Frog
+ *
+ */
+ image: JSX.Element
+ /**
+ * Image options.
+ *
+ * @see https://vercel.com/docs/functions/og-image-generation/og-image-api
+ *
+ * @example
+ * { width: 1200, height: 630 }
+ */
+ imageOptions?: ImageOptions | undefined
+}
+
+export type ImageResponseFn = (
+ response: ImageResponse,
+) => TypedResponse
diff --git a/src/types/response.ts b/src/types/response.ts
index 7d155844..44407757 100644
--- a/src/types/response.ts
+++ b/src/types/response.ts
@@ -6,7 +6,7 @@ export type BaseError = { message: string; statusCode?: ClientErrorStatusCode }
export type BaseErrorResponseFn = (response: BaseError) => TypedResponse
export type TypedResponse = {
- format: 'castAction' | 'frame' | 'transaction'
+ format: 'castAction' | 'frame' | 'transaction' | 'image'
} & OneOf<
{ data: data; status: 'success' } | { error: BaseError; status: 'error' }
>
diff --git a/src/types/routes.ts b/src/types/routes.ts
index 3334a830..b1f0e701 100644
--- a/src/types/routes.ts
+++ b/src/types/routes.ts
@@ -12,10 +12,12 @@ import type {
CastActionContext,
Context,
FrameContext,
+ ImageContext,
TransactionContext,
} from './context.js'
import type { Env } from './env.js'
import type { FrameResponse } from './frame.js'
+import type { ImageResponse } from './image.js'
import type { HandlerResponse } from './response.js'
import type { TransactionResponse } from './transaction.js'
@@ -53,6 +55,12 @@ export type FrameHandler<
I extends Input = BlankInput,
> = (c: FrameContext) => HandlerResponse
+export type ImageHandler<
+ E extends Env = any,
+ P extends string = any,
+ I extends Input = BlankInput,
+> = (c: ImageContext) => HandlerResponse
+
export type TransactionHandler<
E extends Env = any,
P extends string = any,
@@ -85,7 +93,9 @@ export type H<
? TransactionHandler
: M extends 'castAction'
? CastActionHandler
- : Handler
+ : M extends 'image'
+ ? ImageHandler
+ : Handler
////////////////////////////////////////
////// //////
diff --git a/src/utils/getImageContext.ts b/src/utils/getImageContext.ts
new file mode 100644
index 00000000..8ce4830c
--- /dev/null
+++ b/src/utils/getImageContext.ts
@@ -0,0 +1,45 @@
+import { type Context as Context_hono, type Input } from 'hono'
+import type { ImageContext } from '../types/context.js'
+import type { Env } from '../types/env.js'
+
+type GetImageContextParameters<
+ env extends Env = Env,
+ path extends string = string,
+ input extends Input = {},
+ //
+ _state = env['State'],
+> = {
+ context: Context_hono
+}
+
+type GetImageContextReturnType<
+ env extends Env = Env,
+ path extends string = string,
+ input extends Input = {},
+ //
+ _state = env['State'],
+> = {
+ context: ImageContext
+}
+
+export function getImageContext<
+ env extends Env,
+ path extends string,
+ input extends Input = {},
+ //
+ _state = env['State'],
+>(
+ parameters: GetImageContextParameters,
+): GetImageContextReturnType {
+ const { context } = parameters
+ const { env, req } = context || {}
+
+ return {
+ context: {
+ env,
+ req,
+ res: (data) => ({ data, format: 'image', status: 'success' }),
+ var: context.var,
+ },
+ }
+}