Skip to content

Commit

Permalink
feat: state
Browse files Browse the repository at this point in the history
  • Loading branch information
jxom committed Feb 11, 2024
1 parent 07a9da1 commit 8a661cd
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 32 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/** @jsxFrag */

import { Button, Farc, TextInput } from 'farc'
import { app as todoApp } from './todos'

const app = new Farc()

Expand Down Expand Up @@ -140,5 +141,7 @@ app.frame('/falsy-intents', () => {
}
})

app.route('/todos', todoApp)

const server = Bun.serve(app)
console.log(`𝑭𝒂𝒓𝒄 ▶︎ http://localhost:${server.port}/dev`)
65 changes: 65 additions & 0 deletions example/src/todos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** @jsx jsx */
/** @jsxImportSource hono/jsx */
/** @jsxFrag */

import { Button, Farc, TextInput } from 'farc'

type State = {
index: number
todos: { completed: boolean; name: string }[]
}

export const app = new Farc<State>({
initialState: {
index: -1,
todos: [],
},
})

app.frame('/', ({ buttonValue, inputText, deriveState }) => {
const { index, todos } = deriveState((state) => {
if (inputText) state.todos.push({ completed: false, name: inputText })
if (buttonValue === 'up') state.index = Math.max(0, state.index - 1)
if (buttonValue === 'down')
state.index = Math.min(state.todos.length - 1, state.index + 1)
if (buttonValue === 'completed')
state.todos[state.index].completed = !state.todos[state.index].completed
})

return {
image: (
<div
style={{
backgroundColor: 'black',
display: 'flex',
flexDirection: 'column',
padding: 40,
width: '100%',
height: '100%',
}}
>
<div style={{ color: 'white', fontSize: 60 }}>TODO List</div>
{todos.map((todo, i) => (
<div
style={{
color: 'white',
display: 'flex',
fontSize: 40,
marginTop: 20,
textDecoration: 'none',
}}
>
{todo.completed ? '✅' : '◻️'} {todo.name} {i === index ? '👈' : ''}
</div>
))}
</div>
),
intents: [
<TextInput placeholder="Enter a TODO..." />,
<Button>Add</Button>,
<Button value="down">⬇️</Button>,
<Button value="up">⬆️</Button>,
<Button value="completed">{todos[index]?.completed ? '◻️' : '✅'}</Button>,
],
}
})
53 changes: 41 additions & 12 deletions src/farc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ed25519 } from '@noble/curves/ed25519'
import { Hono } from 'hono'
import { ImageResponse } from 'hono-og'
import { inspectRoutes } from 'hono/dev'
import type { HonoOptions } from 'hono/hono-base'
import { jsxRenderer } from 'hono/jsx-renderer'
import { type Env, type Schema } from 'hono/types'

Expand All @@ -24,9 +25,15 @@ import { deserializeJson } from './utils/deserializeJson.js'
import { getFrameContext } from './utils/getFrameContext.js'
import { parseIntents } from './utils/parseIntents.js'
import { parsePath } from './utils/parsePath.js'
import { requestToContext } from './utils/requestToContext.js'
import { serializeJson } from './utils/serializeJson.js'
import { toBaseUrl } from './utils/toBaseUrl.js'

export type FarcConstructorParameters<
state = undefined,
env extends Env = Env,
> = HonoOptions<env> & { initialState?: state | undefined }

export type FrameHandlerReturnType = {
action?: string | undefined
image: JSX.Element
Expand All @@ -35,24 +42,39 @@ export type FrameHandlerReturnType = {
}

export class Farc<
state = undefined,
env extends Env = Env,
schema extends Schema = {},
basePath extends string = '/',
> extends Hono<env, schema, basePath> {
#initialState: state = undefined as state

constructor({ initialState }: FarcConstructorParameters<state, env> = {}) {
super()
if (initialState) this.#initialState = initialState
}

frame<path extends string>(
path: path,
handler: (
context: FrameContext<path>,
previousContext?: PreviousFrameContext | undefined,
context: FrameContext<path, state>,
previousContext: PreviousFrameContext<path, state> | undefined,
) => FrameHandlerReturnType | Promise<FrameHandlerReturnType>,
) {
// Frame Route (implements GET & POST).
this.use(path, async (c) => {
const query = c.req.query()
const previousContext = query.previousContext
? deserializeJson<PreviousFrameContext>(query.previousContext)
? deserializeJson<PreviousFrameContext<path, state>>(
query.previousContext,
)
: undefined
const context = await getFrameContext(c, previousContext)
const context = await getFrameContext({
context: await requestToContext(c.req),
initialState: this.#initialState,
previousContext,
request: c.req,
})

if (context.url !== parsePath(c.req.url))
return c.redirect(
Expand All @@ -69,6 +91,7 @@ export class Farc<
const serializedPreviousContext = serializeJson({
...context,
intents: parsedIntents,
previousState: context.deriveState(),
})

const ogSearch = new URLSearchParams()
Expand Down Expand Up @@ -99,7 +122,9 @@ export class Farc<
<meta
property="fc:frame:post_url"
content={`${
action ? toBaseUrl(c.req.url) + (action || '') : context.url
action
? toBaseUrl(c.req.url) + parsePath(action || '')
: context.url
}?${postSearch}`}
/>
{parsedIntents}
Expand Down Expand Up @@ -132,14 +157,18 @@ export class Farc<
// OG Image Route
this.get(`${parsePath(path)}/image`, async (c) => {
const query = c.req.query()
const parsedContext = deserializeJson<FrameContext>(query.context)
const parsedPreviousContext = query.previousContext
? deserializeJson<PreviousFrameContext>(query.previousContext)
const previousContext = query.previousContext
? deserializeJson<PreviousFrameContext<path, state>>(
query.previousContext,
)
: undefined
const { image } = await handler(
{ ...parsedContext, request: c.req },
parsedPreviousContext,
)
const context = await getFrameContext({
context: deserializeJson<FrameContext<path, state>>(query.context),
initialState: this.#initialState,
previousContext,
request: c.req,
})
const { image } = await handler(context, previousContext)
return new ImageResponse(image)
})

Expand Down
1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@noble/curves": "^1.3.0",
"happy-dom": "^13.3.8",
"hono-og": "~0.0.2",
"immer": "^10.0.3",
"lz-string": "^1.5.0",
"shiki": "^1.0.0"
}
Expand Down
9 changes: 7 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import { type Context, type Env } from 'hono'
import { type JSXNode } from 'hono/jsx'

export type FrameContext<path extends string = string> = {
export type FrameContext<path extends string = string, state = unknown> = {
buttonIndex?: number | undefined
buttonValue?: string | undefined
deriveState: (fn?: (state: state) => void) => state
initialUrl: string
inputText?: string | undefined
previousState: state
request: Context<Env, path>['req']
/**
* Status of the frame in the frame lifecycle.
Expand All @@ -20,7 +22,10 @@ export type FrameContext<path extends string = string> = {
url: Context['req']['url']
}

export type PreviousFrameContext = FrameContext & {
export type PreviousFrameContext<
path extends string = string,
state = unknown,
> = FrameContext<path, state> & {
/** Intents from the previous frame. */
intents: readonly JSXNode[]
}
Expand Down
50 changes: 33 additions & 17 deletions src/utils/getFrameContext.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,60 @@
import { type Context } from 'hono'

import type { Context } from 'hono'
import { produce } from 'immer'
import { type FrameContext, type PreviousFrameContext } from '../types.js'
import { getIntentState } from './getIntentState.js'
import { parsePath } from './parsePath.js'

export async function getFrameContext(
ctx: Context,
previousFrameContext: PreviousFrameContext | undefined,
): Promise<FrameContext> {
const { req } = ctx
const { trustedData, untrustedData } =
(await req.json().catch(() => {})) || {}
type GetFrameContextParameters<state = unknown> = {
context: Pick<
FrameContext<string, state>,
'status' | 'trustedData' | 'untrustedData' | 'url'
>
initialState?: state
previousContext: PreviousFrameContext<string, state> | undefined
request: Context['req']
}

export async function getFrameContext<state>(
options: GetFrameContextParameters<state>,
): Promise<FrameContext<string, state>> {
const { context, previousContext, request } = options
const { trustedData, untrustedData } = context || {}

const { buttonIndex, buttonValue, inputText, reset } = getIntentState(
// TODO: derive from untrusted data.
untrustedData,
previousFrameContext?.intents || [],
previousContext?.intents || [],
)

const status = (() => {
if (reset) return 'initial'
if (req.method === 'POST') return 'response'
return 'initial'
return context.status || 'initial'
})()

// If there are no previous contexts, the initial URL is the current URL.
const initialUrl = !previousFrameContext
? parsePath(req.url)
: previousFrameContext.initialUrl
const initialUrl = !previousContext
? parsePath(context.url)
: previousContext.initialUrl

// If the user has clicked a reset button, we want to set the URL back to the
// initial URL.
const url = reset ? initialUrl : parsePath(req.url)
const url = reset ? initialUrl : parsePath(context.url)

let previousState = previousContext?.previousState || options.initialState
function deriveState(derive?: (state: state) => void): state {
if (status === 'response' && derive)
previousState = produce(previousState, derive)
return previousState as state
}

return {
buttonIndex,
buttonValue,
initialUrl,
inputText,
request: req,
deriveState,
previousState: previousState as any,
request,
status,
trustedData,
untrustedData,
Expand Down
21 changes: 21 additions & 0 deletions src/utils/requestToContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Context } from 'hono'
import type { FrameContext } from '../types.js'

type RequestToContextReturnType = Pick<
FrameContext,
'status' | 'trustedData' | 'untrustedData' | 'url'
>

export async function requestToContext(
request: Context['req'],
): Promise<RequestToContextReturnType> {
const { trustedData, untrustedData } =
(await request.json().catch(() => {})) || {}

return {
status: request.method === 'POST' ? 'response' : 'initial',
trustedData,
untrustedData,
url: request.url,
}
}
1 change: 0 additions & 1 deletion tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"allowSyntheticDefaultImports": false,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"importHelpers": true, // This is only used for build validation. Since we do not have `tslib` installed, this will fail if we accidentally make use of anything that'd require injection of helpers.

// Language and environment
"moduleResolution": "NodeNext",
Expand Down

0 comments on commit 8a661cd

Please sign in to comment.