diff --git a/.changeset/mean-pianos-cheer.md b/.changeset/mean-pianos-cheer.md new file mode 100644 index 00000000..06e0b3f9 --- /dev/null +++ b/.changeset/mean-pianos-cheer.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Added access to `previousState` and `previousButtonValues` in Image Handler. diff --git a/playground/src/initial.tsx b/playground/src/initial.tsx index 6be37879..ef4b83ca 100644 --- a/playground/src/initial.tsx +++ b/playground/src/initial.tsx @@ -1,11 +1,16 @@ import { Button, Frog } from 'frog' import { Heading, VStack, vars } from './ui.js' -export const app = new Frog({ +export const app = new Frog<{ State: { counter: number } }>({ ui: { vars }, + initialState: { counter: 0 }, + verify: false, title: 'Initial', }) .frame('/', (c) => { + c.deriveState((prev) => { + prev.counter++ + }) return c.res({ image: '/refreshing-image/cool-parameter', intents: [], @@ -21,7 +26,8 @@ export const app = new Frog({ image: ( - Current time: {new Date().toISOString()} + Current time: {new Date().toISOString()}. Counter:{' '} + {c.previousState.counter} ), diff --git a/site/pages/concepts/image-handler.mdx b/site/pages/concepts/image-handler.mdx index bd0c7d8a..b62a8f86 100644 --- a/site/pages/concepts/image-handler.mdx +++ b/site/pages/concepts/image-handler.mdx @@ -23,6 +23,7 @@ app.frame('/', (c) => { // [!code focus] }) app.image('/img', (c) => { // [!code focus] + /* Access frame's state via `c.previousState` */ return c.res({/* ... */}) }) ``` @@ -51,6 +52,7 @@ app.frame('/', (c) => { }) app.image('/img', (c) => { + /* Access frame's state via `c.previousState` */ return c.res({ headers: { // [!code focus] 'Cache-Control': 'max-age=0' // [!code focus] diff --git a/site/pages/reference/frog-image-context.mdx b/site/pages/reference/frog-image-context.mdx index 8fee984c..b3bfce7c 100644 --- a/site/pages/reference/frog-image-context.mdx +++ b/site/pages/reference/frog-image-context.mdx @@ -17,6 +17,57 @@ app.image('/', (c) => { // [!code focus] An image handler can also be asynchronous (ie. `async (c) => { ... }{:js}`). ::: +## previousButtonValues + +- **Type**: `string[]` + +The data of the previous intents. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog({ + title: 'Frog Frame' +}) + +app.frame('/', (c) => { + const { previousButtonValues } = c // [!code focus] + return c.res({/* ... */}) +}) +``` + +## previousState + +- **Type**: `State` + +The state of the previous frame. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +type State = { + values: string[] +} + +export const app = new Frog<{ State: State }>({ + initialState: { + values: [] + }, + title: 'Frog Frame', +}) + +app.frame('/', (c) => { + const { previousState } = c // [!code focus] + return c.res({/* ... */}) +}) +``` + ## req - **Type**: `Request` diff --git a/src/frog-base.tsx b/src/frog-base.tsx index 23077d00..499bde08 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -52,6 +52,7 @@ 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 { requestBodyToImageContext } from './utils/requestBodyToImageContext.js' import { serializeJson } from './utils/serializeJson.js' import { toSearchParams } from './utils/toSearchParams.js' import { version } from './version.js' @@ -711,7 +712,15 @@ export class FrogBase< return nonMiddlewareMatchedRoutes.length !== 0 })() - if (isHandlerPresentOnImagePath) return `${baseUrl + parsePath(image)}` + if (isHandlerPresentOnImagePath) + return `${baseUrl + parsePath(image)}${ + context.status !== 'initial' + ? `?${toSearchParams({ + previousState, + previousButtonValues: buttonValues, + }).toString()}` + : '' + }` return `${assetsUrl + parsePath(image)}` })() @@ -928,7 +937,10 @@ export class FrogBase< const assetsUrl = origin + parsePath(this.assetsPath) const { context } = getImageContext({ - context: c, + context: await requestBodyToImageContext(c, { + secret: this.secret, + }), + initialState: this._initialState, }) const response = await handler(context) diff --git a/src/types/context.ts b/src/types/context.ts index 4df9a641..4120899b 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -294,6 +294,8 @@ export type ImageContext< 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. @@ -308,6 +310,14 @@ export type ImageContext< * @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. * diff --git a/src/utils/getImageContext.ts b/src/utils/getImageContext.ts index 6f6369d8..f54d8615 100644 --- a/src/utils/getImageContext.ts +++ b/src/utils/getImageContext.ts @@ -1,5 +1,5 @@ -import type { Context as Context_hono, Input } from 'hono' -import type { ImageContext } from '../types/context.js' +import type { Input } from 'hono' +import type { Context, ImageContext } from '../types/context.js' import type { Env } from '../types/env.js' type GetImageContextParameters< @@ -9,7 +9,11 @@ type GetImageContextParameters< // _state = env['State'], > = { - context: Context_hono + context: Omit< + Context, + 'frameData' | 'verified' | 'status' | 'initialPath' + > + initialState?: _state } type GetImageContextReturnType< @@ -19,7 +23,7 @@ type GetImageContextReturnType< // _state = env['State'], > = { - context: ImageContext + context: ImageContext } export function getImageContext< @@ -31,12 +35,13 @@ export function getImageContext< >( parameters: GetImageContextParameters, ): GetImageContextReturnType { - const { context } = parameters - const { env, req } = context || {} + const { context, initialState } = parameters + const { env, previousState, req } = context || {} return { context: { env, + previousState: previousState ?? (initialState as _state), req, res: (data) => ({ data, format: 'image', status: 'success' }), var: context.var, diff --git a/src/utils/requestBodyToImageContext.ts b/src/utils/requestBodyToImageContext.ts new file mode 100644 index 00000000..d66c3e1d --- /dev/null +++ b/src/utils/requestBodyToImageContext.ts @@ -0,0 +1,56 @@ +import type { Context as Context_hono, Input } from 'hono' +import type { FrogConstructorParameters } from '../frog-base.js' +import type { Context } 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 RequestBodyToImageContextOptions = { + secret?: FrogConstructorParameters['secret'] +} + +type RequestBodyToImageContextReturnType< + env extends Env = Env, + path extends string = string, + input extends Input = {}, + // + _state = env['State'], +> = Omit< + Context, + 'frameData' | 'verified' | 'status' | 'initialPath' +> + +export async function requestBodyToImageContext< + env extends Env, + path extends string, + input extends Input, + // + _state = env['State'], +>( + c: Context_hono, + { secret }: RequestBodyToImageContextOptions, +): 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, + } +}