diff --git a/.changeset/seven-birds-float.md b/.changeset/seven-birds-float.md new file mode 100644 index 00000000..38d5058c --- /dev/null +++ b/.changeset/seven-birds-float.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Introduced `.image` handler to handle images separately from the frame handler. diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 2e6e67c3..d9d0f524 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -13,6 +13,10 @@ runs: cache: pnpm node-version: 20 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - name: Install dependencies shell: bash run: pnpm install diff --git a/playground/src/clock.tsx b/playground/src/clock.tsx deleted file mode 100644 index f0753813..00000000 --- a/playground/src/clock.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Frog } from 'frog' -import { Heading, VStack, vars } from './ui.js' - -export const app = new Frog({ - ui: { vars }, - headers: { 'cache-control': 'max-age=0' }, -}).frame('/', (c) => { - return c.res({ - image: ( - - - Current time: {new Date().toISOString()} - - - ), - }) -}) diff --git a/playground/src/index.tsx b/playground/src/index.tsx index 0b2e8309..21da0dc9 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -5,8 +5,8 @@ import { neynar } from 'frog/hubs' import { Box, Heading, vars } from './ui.js' import { app as castActionApp } from './castAction.js' -import { app as clock } from './clock.js' import { app as fontsApp } from './fonts.js' +import { app as initial } from './initial.js' import { app as middlewareApp } from './middleware.js' import { app as neynarApp } from './neynar.js' import { app as routingApp } from './routing.js' @@ -191,7 +191,7 @@ export const app = new Frog({ return c.error({ message: 'Bad inputs!' }) }) .route('/castAction', castActionApp) - .route('/clock', clock) + .route('/initial', initial) .route('/ui', uiSystemApp) .route('/fonts', fontsApp) .route('/middleware', middlewareApp) diff --git a/playground/src/initial.tsx b/playground/src/initial.tsx new file mode 100644 index 00000000..0008e672 --- /dev/null +++ b/playground/src/initial.tsx @@ -0,0 +1,28 @@ +import { Button, Frog } from 'frog' +import { Heading, VStack, vars } from './ui.js' + +export const app = new Frog({ + ui: { vars }, +}) + .frame('/', (c) => { + return c.res({ + image: '/refreshing-image/cool-parameter', + intents: [], + }) + }) + .image('/refreshing-image/:param', (c) => { + return c.res({ + imageOptions: { + headers: { + 'Cache-Control': 'max-age=0', + }, + }, + image: ( + + + Current time: {new Date().toISOString()} + + + ), + }) + }) diff --git a/site/pages/concepts/image-handler.mdx b/site/pages/concepts/image-handler.mdx new file mode 100644 index 00000000..bd0c7d8a --- /dev/null +++ b/site/pages/concepts/image-handler.mdx @@ -0,0 +1,61 @@ +# Image Handler [Moving image rendering into another handler.] + +Internally, **Frog** serves an image handler for every Frame at the frame path + `/image` endpoint. +Although it comes with ease, this approach has several limitations: +- Making *refreshing frames* would not be possible as initial frame response is cached indefinitely and image URL would change +if the image changes. +- If your Frame is heavily composed of different UI elements, browsers might *cut* a part of image URL that contains the compressed +image in the query parameter, making it fail to render. + +In order to mitigate that, **Frog** has `.image` handler that can be used to serve an image at a *static URL*. + +```tsx twoslash +// @noErrors +import { Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { // [!code focus] + return c.res({ + image: '/img' + /* ... */ + }) +}) + +app.image('/img', (c) => { // [!code focus] + return c.res({/* ... */}) +}) +``` + +Since the image URL is static now, you're open to add `Cache-Control: max-age=0` header to image response to achieve refreshing initial frame. + +:::warning +By default, image response will have `Cache-Control: public, immutable, no-transform, max-age=31536000` header set. +However, since HTTP headers are case-insensitive, different frameworks (i.e. Next.JS, Vercel) might +treat them differently and use lowercased format of such. + +Thus, if you're overriding the `Cache-Control` header and can't see the changes to kick in – try lowercasing the header. +::: + +```tsx twoslash +// @noErrors +import { Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + image: '/img' + /* ... */ + }) +}) + +app.image('/img', (c) => { + return c.res({ + headers: { // [!code focus] + 'Cache-Control': 'max-age=0' // [!code focus] + }, // [!code focus] + /* ... */ + }) +}) +``` diff --git a/site/pages/reference/frog-cast-action-response.mdx b/site/pages/reference/frog-cast-action-response.mdx index 9aa2bbac..c6276427 100644 --- a/site/pages/reference/frog-cast-action-response.mdx +++ b/site/pages/reference/frog-cast-action-response.mdx @@ -9,35 +9,15 @@ import { Frog } from 'frog' export const app = new Frog() app.castAction('/', (c) => { - return c.res({ // [!code focus] - // ... // [!code focus] - }) // [!code focus] -}) -``` - -## headers - -- **Type:** `Record` - -HTTP response headers to set for the action. - -```tsx twoslash -// @noErrors -/** @jsxImportSource frog/jsx */ -// ---cut--- -import { Button, Frog } from 'frog' - -export const app = new Frog() - -app.castAction('/action/foo', (c) => { - return c.res({ - headers: { // [!code focus] - 'Cache-Control': 'max-age=0', // [!code focus] - }, // [!code focus] - message: 'Success!', - statusCode: 200, - }) -}) + return c.res({ // [!code focus] + // ... // [!code focus] + }) // [!code focus] + }, + { + name: 'My Action', + icon: 'log' + } +) ``` ## message @@ -54,15 +34,20 @@ import { Button, Frog } from 'frog' export const app = new Frog() app.castAction('/', (c) => { - return c.res({ - message: 'Action Succeeded!', // [!code focus] - statusCode: 200, - }) -}) + return c.res({ + message: 'Action Succeeded!', // [!code focus] + type: 'message' + }) + }, + { + name: 'My Action', + icon: 'log' + } +) ``` ## link -# + :::warning The `link` property is valid to be added only in response with HTTP status 200. Thus you cannot add one in `.error()` response. @@ -81,34 +66,15 @@ import { Button, Frog } from 'frog' export const app = new Frog() app.castAction('/', (c) => { - return c.res({ - message: 'Action Succeeded!', - link: 'https://frog.fm', // [!code focus] - statusCode: 200, - }) -}) + return c.res({ + message: 'Action Succeeded!', + link: 'https://frog.fm', // [!code focus] + type: 'message', + }) + }, + { + name: 'My Action', + icon: 'log' + } +) ``` - -## statusCode - -- **Type:** `200 | ClientErrorStatusCode | undefined` -- **Default:** `200`. - -HTTP Status code to respond with. - -```tsx twoslash -/** @jsxImportSource frog/jsx */ -// ---cut--- -import { Button, Frog } from 'frog' - -export const app = new Frog() - -app.castAction('/', (c) => { - return c.res({ - message: 'Action Succeeded!', - statusCode: 200, // [!code focus] - }) -}) -``` - - diff --git a/site/pages/reference/frog-cast-action.mdx b/site/pages/reference/frog-cast-action.mdx index bcc4b474..2a3c8500 100644 --- a/site/pages/reference/frog-cast-action.mdx +++ b/site/pages/reference/frog-cast-action.mdx @@ -16,9 +16,13 @@ import { Button, Frog } from 'frog' const app = new Frog() app.castAction('/', (c) => { // [!code focus] - console.log('Apple and Banana') - return c.res({ message: 'Action Succeeded' }) // [!code focus] -}) // [!code focus] + return c.message({ message: 'Action Succeeded' }) // [!code focus] + }, // [!code focus] + { // [!code focus] + name: 'My Action', // [!code focus] + icon: 'log' // [!code focus] + } // [!code focus] +) // [!code focus] ``` ## Parameters @@ -41,10 +45,13 @@ const app = new Frog() app.castAction( '/foo/bar', // [!code focus] (c) => { - console.log('Apple and Banana') - return c.res({ message: 'Action Succeeded' }) // [!code focus] - } -) + return c.message({ message: 'Action Succeeded' }) // [!code focus] + }, // [!code focus] + { // [!code focus] + name: 'My Action', // [!code focus] + icon: 'log' // [!code focus] + } // [!code focus] +) // [!code focus] ``` ### handler @@ -63,9 +70,39 @@ const app = new Frog() app.castAction( '/foo/bar', (c) => { // [!code focus] - console.log('Apple and Banana') - return c.res({ message: 'Action Succeeded' }) // [!code focus] - } // [!code focus] + return c.message({ message: 'Action Succeeded' }) // [!code focus] + }, // [!code focus] + { + name: 'My Action', + icon: 'log' + } +) +``` + +### options + +- **Type:** `RouteOptions<'castAction'>` + +Options for a Cast Action + +```tsx twoslash +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +const app = new Frog() + +app.castAction( + '/foo/bar', + (c) => { + return c.message({ message: 'Action Succeeded' }) + }, + { + aboutUrl: 'https://frog.fm/reference/frog-cast-action', // [!code focus] + name: 'My Action', // [!code focus] + description: 'My awesome action.', // [!code focus] + icon: 'log' // [!code focus] + } ) ``` diff --git a/site/pages/reference/frog-image-context.mdx b/site/pages/reference/frog-image-context.mdx new file mode 100644 index 00000000..8fee984c --- /dev/null +++ b/site/pages/reference/frog-image-context.mdx @@ -0,0 +1,82 @@ +# Frog.image Context + +The `c` object is the parameter of the route handlers. + +```tsx twoslash +// @noErrors +import { Frog } from 'frog' + +export const app = new Frog() + +app.image('/', (c) => { // [!code focus] + return c.res({/* ... */}) +}) +``` + +:::tip[Tip] +An image handler can also be asynchronous (ie. `async (c) => { ... }{:js}`). +::: + +## req + +- **Type**: `Request` + +[Hono request object](https://hono.dev/api/request). + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.image('/', (c) => { + const { req } = c // [!code focus] + return c.res({/* ... */}) +}) +``` + +## res + +- **Type**: `(response: ImageResponse) => ImageResponse` + +The image response. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.image('/', (c) => { + return c.res({/* ... */}) // [!code focus] +}) +``` + +## var + +- **Type**: `HonoContext['var']` + +Extract a context value that was previously set via [`set`](#set) in [Middleware](/concepts/middleware). + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.use(async (c, next) => { + c.set('message', 'Frog is cool!!') + await next() +}) + +app.image('/', (c) => { + const message = c.var.message // [!code focus] + return c.res({/* ... */}) +}) +``` diff --git a/site/pages/reference/frog-image-response.mdx b/site/pages/reference/frog-image-response.mdx new file mode 100644 index 00000000..9d26d6c4 --- /dev/null +++ b/site/pages/reference/frog-image-response.mdx @@ -0,0 +1,132 @@ +# Frog.image Response + +The response returned from the `.image` handler. + +```tsx twoslash +// @noErrors +import { Frog } from 'frog' + +export const app = new Frog() + +app.image('/', (c) => { + return c.res({ // [!code focus] + // ... // [!code focus] + }) // [!code focus] +}) +``` + +## headers + +- **Type:** `Record` + +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, + }, + } +}