Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: slim post url #5

Merged
merged 1 commit into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/_dev/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Farc, TextInput } from 'farc'
import { app as todoApp } from './todos'

const app = new Farc({
// basePath: '/api'
// basePath: '/api',
})

app.frame('/', (context) => {
Expand Down
43 changes: 20 additions & 23 deletions src/farc-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from './types.js'
import { deserializeJson } from './utils/deserializeJson.js'
import { getFrameContext } from './utils/getFrameContext.js'
import { getIntentData } from './utils/getIntentData.js'
import { parseIntents } from './utils/parseIntents.js'
import { parsePath } from './utils/parsePath.js'
import { requestToContext } from './utils/requestToContext.js'
Expand Down Expand Up @@ -44,6 +45,7 @@ export class FarcBase<
> {
#initialState: state = undefined as state

basePath: string
hono: Hono<env, schema, basePath>
fetch: Hono<env, schema, basePath>['fetch']
get: Hono<env, schema, basePath>['get']
Expand All @@ -56,6 +58,7 @@ export class FarcBase<
}: FarcConstructorParameters<state, env, basePath> = {}) {
this.hono = new Hono<env, schema, basePath>(honoOptions)
if (basePath) this.hono = this.hono.basePath(basePath)
this.basePath = 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)
Expand All @@ -67,20 +70,15 @@ export class FarcBase<
path: path,
handler: (
context: FrameContext<path, state>,
previousContext: PreviousFrameContext<path, state> | undefined,
) => FrameHandlerReturnType | Promise<FrameHandlerReturnType>,
) {
// Frame Route (implements GET & POST).
this.hono.use(parsePath(path), async (c) => {
const query = c.req.query()

const url = new URL(c.req.url)
const baseUrl = `${url.origin}${url.pathname}`

const previousContext = query.previousContext
? deserializeJson<PreviousFrameContext<path, state>>(
query.previousContext,
)
? deserializeJson<PreviousFrameContext<state>>(query.previousContext)
: undefined
const context = await getFrameContext({
context: await requestToContext(c.req),
Expand All @@ -94,22 +92,19 @@ export class FarcBase<
if (!location) throw new Error('location required to redirect')
return c.redirect(location, 302)
}
if (context.url !== parsePath(c.req.url)) return c.redirect(context.url)

if (context.url !== parsePath(c.req.url))
return c.redirect(
`${context.url}?previousContext=${query.previousContext}`,
)

const { action, imageAspectRatio, intents } = await handler(
context,
previousContext,
)
const { action, imageAspectRatio, intents } = await handler(context)
const parsedIntents = intents ? parseIntents(intents) : null
const intentData = getIntentData(parsedIntents)

const serializedContext = serializeJson(context)
const serializedPreviousContext = serializeJson({
const serializedContext = serializeJson({
...context,
intents: parsedIntents,
request: undefined,
})
const serializedPreviousContext = serializeJson({
initialUrl: context.initialUrl,
intentData,
previousState: context.deriveState(),
})

Expand Down Expand Up @@ -141,7 +136,11 @@ export class FarcBase<
<meta
property="fc:frame:post_url"
content={`${
action ? baseUrl + parsePath(action || '') : context.url
action
? url.origin +
parsePath(this.basePath) +
parsePath(action || '')
: context.url
}?${postSearch}`}
/>
{parsedIntents}
Expand Down Expand Up @@ -175,17 +174,15 @@ export class FarcBase<
this.hono.get(`${parsePath(path)}/image`, async (c) => {
const query = c.req.query()
const previousContext = query.previousContext
? deserializeJson<PreviousFrameContext<path, state>>(
query.previousContext,
)
? deserializeJson<PreviousFrameContext<state>>(query.previousContext)
: undefined
const context = await getFrameContext({
context: deserializeJson<FrameContext<path, state>>(query.context),
initialState: this.#initialState,
previousContext,
request: c.req,
})
const { image } = await handler(context, previousContext)
const { image } = await handler(context)
return new ImageResponse(image)
})
}
Expand Down
3 changes: 1 addition & 2 deletions src/farc.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Env, Schema } from 'hono'
import { routes as devRoutes } from './dev/routes.js'
import { FarcBase, type FrameHandlerReturnType } from './farc-base.js'
import type { FrameContext, PreviousFrameContext } from './types.js'
import type { FrameContext } from './types.js'

export class Farc<
state = undefined,
Expand All @@ -13,7 +13,6 @@ export class Farc<
path: path,
handler: (
context: FrameContext<path, state>,
previousContext: PreviousFrameContext<path, state> | undefined,
) => FrameHandlerReturnType | Promise<FrameHandlerReturnType>,
) {
super.frame(path, handler)
Expand Down
18 changes: 8 additions & 10 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
// TODO: TSDoc

import { type Context, type Env } from 'hono'
import { type JSXNode } from 'hono/jsx'

export type FrameContext<path extends string = string, state = unknown> = {
buttonIndex?: number | undefined
buttonValue?: string | undefined
deriveState: (fn?: (state: state) => void) => state
frameData: FrameData
initialUrl: string
inputText?: string | undefined
previousState: state
Expand All @@ -18,17 +17,14 @@ export type FrameContext<path extends string = string, state = unknown> = {
* - `response` - The frame has been interacted with (user presses button).
*/
status: 'initial' | 'redirect' | 'response'
trustedData?: TrustedData | undefined
untrustedData?: UntrustedData | undefined
url: Context['req']['url']
}

export type PreviousFrameContext<
path extends string = string,
state = unknown,
> = FrameContext<path, state> & {
/** Intents from the previous frame. */
intents: readonly JSXNode[]
export type PreviousFrameContext<state = unknown> = {
initialUrl: string
/** Intent data from the previous frame. */
intentData: readonly Record<string, string>[]
previousState: state
}

export type FrameData = {
Expand All @@ -49,6 +45,8 @@ export type FrameVersion = 'vNext'
export type FrameIntent = JSX.Element | false | null | undefined
export type FrameIntents = FrameIntent | FrameIntent[]

export type FrameIntentData = Record<string, string>

export type TrustedData = {
messageBytes: string
}
Expand Down
23 changes: 8 additions & 15 deletions src/utils/getFrameContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,22 @@ import { getIntentState } from './getIntentState.js'
import { parsePath } from './parsePath.js'

type GetFrameContextParameters<state = unknown> = {
context: Pick<
FrameContext<string, state>,
'status' | 'trustedData' | 'untrustedData' | 'url'
>
context: Pick<FrameContext<string, state>, 'frameData' | 'status' | 'url'>
initialState?: state
previousContext: PreviousFrameContext<string, state> | undefined
previousContext: PreviousFrameContext<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 { frameData } = context || {}

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

const status = (() => {
if (redirect) return 'redirect'
Expand All @@ -50,16 +45,14 @@ export async function getFrameContext<state>(
}

return {
buttonIndex,
buttonValue,
frameData,
initialUrl,
inputText,
deriveState,
previousState: previousState as any,
request,
status,
trustedData,
untrustedData,
url,
}
}
25 changes: 25 additions & 0 deletions src/utils/getIntentData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { JSXNode } from 'hono/jsx'
import type { FrameIntentData } from '../types.js'

export function getIntentData(
intents: readonly JSXNode[] | null,
): FrameIntentData[] {
if (!intents) return []

const intentData: FrameIntentData[] = []
for (const intent of intents) {
if (!intent) continue
const { property } = intent.props
const data: FrameIntentData = {}
for (const [key, value] of Object.entries(intent.props)) {
if (!key.startsWith('data-')) continue
data[key] = value
}
if (Object.keys(data).length === 0) continue
intentData.push({
property,
...data,
})
}
return intentData
}
25 changes: 14 additions & 11 deletions src/utils/getIntentState.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { type JSXNode } from 'hono/jsx'
import { type FrameData, type FrameIntentData } from '../types.js'

import { type FrameData } from '../types.js'
type IntentState = {
buttonValue: string | undefined
inputText: string | undefined
redirect: boolean
reset: boolean
}

export function getIntentState(
frameData: FrameData | undefined,
intents: readonly JSXNode[] | null,
intentData: readonly FrameIntentData[] | null,
) {
const { buttonIndex, inputText } = frameData || {}

const state = {
buttonIndex,
const state: IntentState = {
buttonValue: undefined,
inputText,
redirect: false,
reset: false,
}
if (!intents) return state
if (!intentData) return state

if (buttonIndex) {
const buttonIntents = intents.filter((intent) =>
intent?.props?.property?.match(/fc:frame:button:\d$/),
const buttonIntents = intentData.filter((intent) =>
intent?.property?.match(/fc:frame:button:\d$/),
)
const intent = buttonIntents[buttonIndex - 1]

const type = intent.props['data-type']
const type = intent['data-type']
if (type === 'redirect') state.redirect = true
else if (type === 'reset') state.reset = true

const value = intent.props['data-value']
const value = intent['data-value']
state.buttonValue = value
}

Expand Down
7 changes: 3 additions & 4 deletions src/utils/requestToContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@ import type { FrameContext } from '../types.js'

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

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

return {
frameData: untrustedData,
status: request.method === 'POST' ? 'response' : 'initial',
trustedData,
untrustedData,
url: request.url,
}
}