From 2fdc8abc61795d32282dea78716287fde1079b02 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Fri, 9 Feb 2024 14:14:35 +1100 Subject: [PATCH] feat: cleanup --- scripts/preconstruct.ts | 6 +- src/components/Button.tsx | 81 +++ src/components/TextInput.tsx | 8 + src/farc.tsx | 256 +++++++ src/index.ts | 10 + src/index.tsx | 670 ------------------- src/{preview.tsx => preview/components.tsx} | 9 +- src/preview/types.ts | 63 ++ src/{index.test.ts => preview/utils.test.ts} | 2 +- src/preview/utils.ts | 215 ++++++ src/types.ts | 75 +-- src/utils/deserializeJson.ts | 6 + src/utils/getFrameContext.ts | 37 + src/utils/getIntentState.ts | 20 + src/utils/parseIntents.ts | 41 ++ src/utils/serializeJson.ts | 5 + src/utils/toBaseUrl.ts | 5 + tsconfig.json | 5 +- 18 files changed, 763 insertions(+), 751 deletions(-) create mode 100644 src/components/Button.tsx create mode 100644 src/components/TextInput.tsx create mode 100644 src/farc.tsx create mode 100644 src/index.ts delete mode 100644 src/index.tsx rename src/{preview.tsx => preview/components.tsx} (98%) create mode 100644 src/preview/types.ts rename src/{index.test.ts => preview/utils.test.ts} (99%) create mode 100644 src/preview/utils.ts create mode 100644 src/utils/deserializeJson.ts create mode 100644 src/utils/getFrameContext.ts create mode 100644 src/utils/getIntentState.ts create mode 100644 src/utils/parseIntents.ts create mode 100644 src/utils/serializeJson.ts create mode 100644 src/utils/toBaseUrl.ts diff --git a/scripts/preconstruct.ts b/scripts/preconstruct.ts index 99ae48cc..956e4439 100644 --- a/scripts/preconstruct.ts +++ b/scripts/preconstruct.ts @@ -56,7 +56,7 @@ for (const packagePath of packagePaths) { // Skip `package.json` exports if (/package\.json$/.test(key)) continue - let entries: string[][] + let entries: any if (typeof exports === 'string') entries = [ ['default', exports], @@ -74,8 +74,8 @@ for (const packagePath of packagePaths) { path.dirname(value).replace(distDirName, ''), ) let srcFileName: string - if (key === '.') srcFileName = 'index.tsx' - else srcFileName = path.basename(`${key}.tsx`) + if (key === '.') srcFileName = 'index.ts' + else srcFileName = path.basename(`${key}.ts`) const srcFilePath = path.resolve(srcDir, srcFileName) const distDir = path.resolve(dir, path.dirname(value)) diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 00000000..a73e281a --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,81 @@ +import type { HtmlEscapedString } from 'hono/utils/html' + +export type ButtonProps = { + children: string + index?: number | undefined +} + +export type ButtonRootProps = ButtonProps & { + action?: 'post' | 'post_redirect' + value?: string | undefined +} + +ButtonRoot.__type = 'button' +export function ButtonRoot({ + action = 'post', + children, + index = 0, + value, +}: ButtonRootProps) { + return [ + , + , + ] as unknown as HtmlEscapedString +} + +export type ButtonLinkProps = ButtonProps & { + href: string +} + +ButtonLink.__type = 'button' +export function ButtonLink({ children, index = 0, href }: ButtonLinkProps) { + return [ + , + , + , + ] as unknown as HtmlEscapedString +} + +export type ButtonMintProps = ButtonProps & { + target: string +} + +ButtonMint.__type = 'button' +export function ButtonMint({ children, index = 0, target }: ButtonMintProps) { + return [ + , + , + , + ] as unknown as HtmlEscapedString +} + +export type ButtonResetProps = ButtonProps + +ButtonReset.__type = 'button' +export function ButtonReset({ children, index = 0 }: ButtonResetProps) { + return ( + + ) +} + +export const Button = Object.assign(ButtonRoot, { + Link: ButtonLink, + Mint: ButtonMint, + Reset: ButtonReset, +}) diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx new file mode 100644 index 00000000..ba378c19 --- /dev/null +++ b/src/components/TextInput.tsx @@ -0,0 +1,8 @@ +export type TextInputProps = { + placeholder?: string | undefined +} + +TextInput.__type = 'text-input' +export function TextInput({ placeholder }: TextInputProps) { + return +} diff --git a/src/farc.tsx b/src/farc.tsx new file mode 100644 index 00000000..c1874996 --- /dev/null +++ b/src/farc.tsx @@ -0,0 +1,256 @@ +import { + FrameActionBody, + Message, + NobleEd25519Signer, + makeFrameAction, +} from '@farcaster/core' +import { bytesToHex } from '@noble/curves/abstract/utils' +import { ed25519 } from '@noble/curves/ed25519' +import { Hono } from 'hono' +import { ImageResponse } from 'hono-og' +import { jsxRenderer } from 'hono/jsx-renderer' +import { type Env, type Schema } from 'hono/types' + +import { Preview, previewStyles } from './preview/components.js' +import { htmlToFrame, htmlToState } from './preview/utils.js' +import { + type FrameContext, + type FrameIntents, + type PreviousFrameContext, +} from './types.js' +import { deserializeJson } from './utils/deserializeJson.js' +import { getFrameContext } from './utils/getFrameContext.js' +import { parseIntents } from './utils/parseIntents.js' +import { serializeJson } from './utils/serializeJson.js' +import { toBaseUrl } from './utils/toBaseUrl.js' + +export type FrameHandlerReturnType = { + // TODO: Support `fc:frame:image:aspect_ratio` + image: JSX.Element + intents?: FrameIntents | undefined +} + +export class Farc< + E extends Env = Env, + S extends Schema = {}, + BasePath extends string = '/', +> extends Hono { + frame

( + path: P, + handler: ( + context: FrameContext

, + previousContext?: PreviousFrameContext | undefined, + ) => FrameHandlerReturnType | Promise, + ) { + // Frame Route (implements GET & POST). + this.use(path, async (c) => { + const query = c.req.query() + const previousContext = query.previousContext + ? deserializeJson(query.previousContext) + : undefined + const context = await getFrameContext(c, previousContext) + + const { intents } = await handler(context, previousContext) + const parsedIntents = intents ? parseIntents(intents) : null + + const serializedContext = serializeJson(context) + const serializedPreviousContext = serializeJson({ + ...context, + intents: parsedIntents, + }) + + const ogSearch = new URLSearchParams() + if (query.previousContext) + ogSearch.set('previousContext', query.previousContext) + if (serializedContext) ogSearch.set('context', serializedContext) + + const postSearch = new URLSearchParams() + if (serializedPreviousContext) + postSearch.set('previousContext', serializedPreviousContext) + + return c.render( + + + + + + + {parsedIntents} + + + {query.previousContext && ( + + )} + + , + ) + }) + + // OG Image Route + this.get(`${toBaseUrl(path)}/image`, async (c) => { + const query = c.req.query() + const parsedContext = deserializeJson(query.context) + const parsedPreviousContext = query.previousContext + ? deserializeJson(query.previousContext) + : undefined + const { image } = await handler( + { ...parsedContext, request: c.req }, + parsedPreviousContext, + ) + return new ImageResponse(image) + }) + + // Frame Preview Routes + this.use( + `${toBaseUrl(path)}/preview`, + jsxRenderer( + (props) => { + const { children } = props + return ( + + + 𝑭𝒂𝒓𝒄 ▶︎ {path} + + + {children} + + ) + }, + { docType: true }, + ), + ) + .get(async (c) => { + const baseUrl = c.req.url.replace('/preview', '') + const response = await fetch(baseUrl) + const text = await response.text() + const frame = htmlToFrame(text) + const state = htmlToState(text) + return c.render() + }) + .post(async (c) => { + const baseUrl = c.req.url.replace('/preview', '') + + const formData = await c.req.formData() + const buttonIndex = parseInt( + typeof formData.get('buttonIndex') === 'string' + ? (formData.get('buttonIndex') as string) + : '', + ) + // TODO: Sanitize input + const inputText = formData.get('inputText') + ? Buffer.from(formData.get('inputText') as string) + : undefined + + const privateKeyBytes = ed25519.utils.randomPrivateKey() + // const publicKeyBytes = await ed.getPublicKeyAsync(privateKeyBytes) + + // const key = bytesToHex(publicKeyBytes) + // const deadline = Math.floor(Date.now() / 1000) + 60 * 60 // now + hour + // + // const account = privateKeyToAccount(bytesToHex(privateKeyBytes)) + // const requestFid = 1 + + // const signature = await account.signTypedData({ + // domain: { + // name: 'Farcaster SignedKeyRequestValidator', + // version: '1', + // chainId: 10, + // verifyingContract: '0x00000000FC700472606ED4fA22623Acf62c60553', + // }, + // types: { + // SignedKeyRequest: [ + // { name: 'requestFid', type: 'uint256' }, + // { name: 'key', type: 'bytes' }, + // { name: 'deadline', type: 'uint256' }, + // ], + // }, + // primaryType: 'SignedKeyRequest', + // message: { + // requestFid: BigInt(requestFid), + // key, + // deadline: BigInt(deadline), + // }, + // }) + + // const response = await fetch( + // 'https://api.warpcast.com/v2/signed-key-requests', + // { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ + // deadline, + // key, + // requestFid, + // signature, + // }), + // }, + // ) + + const fid = 2 + const castId = { + fid, + hash: new Uint8Array( + Buffer.from('0000000000000000000000000000000000000000', 'hex'), + ), + } + const frameActionBody = FrameActionBody.create({ + url: Buffer.from(baseUrl), + buttonIndex, + castId, + inputText, + }) + const frameActionMessage = await makeFrameAction( + frameActionBody, + { fid, network: 1 }, + new NobleEd25519Signer(privateKeyBytes), + ) + + const message = frameActionMessage._unsafeUnwrap() + const response = await fetch(formData.get('action') as string, { + method: 'POST', + body: JSON.stringify({ + untrustedData: { + buttonIndex, + castId: { + fid: castId.fid, + hash: `0x${bytesToHex(castId.hash)}`, + }, + fid, + inputText: inputText + ? Buffer.from(inputText).toString('utf-8') + : undefined, + messageHash: `0x${bytesToHex(message.hash)}`, + network: 1, + timestamp: message.data.timestamp, + url: baseUrl, + }, + trustedData: { + messageBytes: Buffer.from( + Message.encode(message).finish(), + ).toString('hex'), + }, + }), + }) + const text = await response.text() + // TODO: handle redirects + const frame = htmlToFrame(text) + const state = htmlToState(text) + + return c.render() + }) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..6e387c65 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ +export { + Button, + type ButtonLinkProps, + type ButtonMintProps, + type ButtonProps, + type ButtonResetProps, +} from './components/Button.js' +export { TextInput, type TextInputProps } from './components/TextInput.js' + +export { Farc, type FrameHandlerReturnType } from './farc.js' diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index c13d5218..00000000 --- a/src/index.tsx +++ /dev/null @@ -1,670 +0,0 @@ -import { - FrameActionBody, - Message, - NobleEd25519Signer, - makeFrameAction, -} from '@farcaster/core' -import { bytesToHex } from '@noble/curves/abstract/utils' -import { ed25519 } from '@noble/curves/ed25519' -import { Window } from 'happy-dom' -import { type Context, Hono } from 'hono' -import { ImageResponse } from 'hono-og' -import { type JSXNode } from 'hono/jsx' -import { jsxRenderer } from 'hono/jsx-renderer' -import { type Env, type Schema } from 'hono/types' -import { type HtmlEscapedString } from 'hono/utils/html' -import { - compressToEncodedURIComponent, - decompressFromEncodedURIComponent, -} from 'lz-string' - -import { Preview, previewStyles } from './preview.js' -import { - type FarcMetaTagPropertyName, - type Frame, - type FrameButton, - type FrameContext, - type FrameData, - type FrameImageAspectRatio, - type FrameMetaTagPropertyName, - type FrameVersion, - type PreviousFrameContext, -} from './types.js' - -type Intent = JSX.Element | false | null | undefined -type Intents = Intent | Intent[] -type FrameHandlerReturnType = { - // TODO: Support `fc:frame:image:aspect_ratio` - image: JSX.Element - intents?: Intents | undefined -} -export class Farc< - E extends Env = Env, - S extends Schema = {}, - BasePath extends string = '/', -> extends Hono { - frame

( - path: P, - handler: ( - context: FrameContext

, - previousContext?: PreviousFrameContext | undefined, - ) => FrameHandlerReturnType | Promise, - ) { - // Frame Route (implements GET & POST). - this.use(path, async (c) => { - const query = c.req.query() - const previousContext = query.previousContext - ? deserializeJson(query.previousContext) - : undefined - const context = await getFrameContext(c, previousContext) - - const { intents } = await handler(context, previousContext) - const parsedIntents = intents ? parseIntents(intents) : null - - const serializedContext = serializeJson(context) - const serializedPreviousContext = serializeJson({ - ...previousContext, - intents: parsedIntents, - }) - - const ogSearch = new URLSearchParams() - if (query.previousContext) - ogSearch.set('previousContext', query.previousContext) - if (serializedContext) ogSearch.set('context', serializedContext) - - const postSearch = new URLSearchParams() - if (serializedPreviousContext) - postSearch.set('previousContext', serializedPreviousContext) - - return c.render( - - - - - - - {parsedIntents} - - - {previousContext && ( - - )} - - , - ) - }) - - // OG Image Route - this.get(`${toBaseUrl(path)}/image`, async (c) => { - const query = c.req.query() - const parsedContext = deserializeJson(query.context) - const parsedPreviousContext = query.previousContext - ? deserializeJson(query.previousContext) - : undefined - const { image } = await handler( - { ...parsedContext, request: c.req }, - parsedPreviousContext, - ) - return new ImageResponse(image) - }) - - // Frame Preview Routes - this.use( - `${toBaseUrl(path)}/preview`, - jsxRenderer( - (props) => { - const { children } = props - return ( - - - 𝑭𝒂𝒓𝒄 ▶︎ {path} - - - {children} - - ) - }, - { docType: true }, - ), - ) - .get(async (c) => { - const baseUrl = c.req.url.replace('/preview', '') - const response = await fetch(baseUrl) - const text = await response.text() - const frame = htmlToFrame(text) - const state = htmlToState(text) - return c.render() - }) - .post(async (c) => { - const baseUrl = c.req.url.replace('/preview', '') - - const formData = await c.req.formData() - const buttonIndex = parseInt( - typeof formData.get('buttonIndex') === 'string' - ? (formData.get('buttonIndex') as string) - : '', - ) - // TODO: Sanitize input - const inputText = formData.get('inputText') - ? Buffer.from(formData.get('inputText') as string) - : undefined - - const privateKeyBytes = ed25519.utils.randomPrivateKey() - // const publicKeyBytes = await ed.getPublicKeyAsync(privateKeyBytes) - - // const key = bytesToHex(publicKeyBytes) - // const deadline = Math.floor(Date.now() / 1000) + 60 * 60 // now + hour - // - // const account = privateKeyToAccount(bytesToHex(privateKeyBytes)) - // const requestFid = 1 - - // const signature = await account.signTypedData({ - // domain: { - // name: 'Farcaster SignedKeyRequestValidator', - // version: '1', - // chainId: 10, - // verifyingContract: '0x00000000FC700472606ED4fA22623Acf62c60553', - // }, - // types: { - // SignedKeyRequest: [ - // { name: 'requestFid', type: 'uint256' }, - // { name: 'key', type: 'bytes' }, - // { name: 'deadline', type: 'uint256' }, - // ], - // }, - // primaryType: 'SignedKeyRequest', - // message: { - // requestFid: BigInt(requestFid), - // key, - // deadline: BigInt(deadline), - // }, - // }) - - // const response = await fetch( - // 'https://api.warpcast.com/v2/signed-key-requests', - // { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // }, - // body: JSON.stringify({ - // deadline, - // key, - // requestFid, - // signature, - // }), - // }, - // ) - - const fid = 2 - const castId = { - fid, - hash: new Uint8Array( - Buffer.from('0000000000000000000000000000000000000000', 'hex'), - ), - } - const frameActionBody = FrameActionBody.create({ - url: Buffer.from(baseUrl), - buttonIndex, - castId, - inputText, - }) - const frameActionMessage = await makeFrameAction( - frameActionBody, - { fid, network: 1 }, - new NobleEd25519Signer(privateKeyBytes), - ) - - const message = frameActionMessage._unsafeUnwrap() - const response = await fetch(formData.get('action') as string, { - method: 'POST', - body: JSON.stringify({ - untrustedData: { - buttonIndex, - castId: { - fid: castId.fid, - hash: `0x${bytesToHex(castId.hash)}`, - }, - fid, - inputText: inputText - ? Buffer.from(inputText).toString('utf-8') - : undefined, - messageHash: `0x${bytesToHex(message.hash)}`, - network: 1, - timestamp: message.data.timestamp, - url: baseUrl, - }, - trustedData: { - messageBytes: Buffer.from( - Message.encode(message).finish(), - ).toString('hex'), - }, - }), - }) - const text = await response.text() - // TODO: handle redirects - const frame = htmlToFrame(text) - const state = htmlToState(text) - - return c.render() - }) - - // TODO: fix this – does it work? - // Package up the above routes into `path`. - // this.route(path, this) - } -} - -//////////////////////////////////////////////////////////////////////// -// Components -//////////////////////////////////////////////////////////////////////// - -export type ButtonProps = { - children: string - index?: number | undefined -} - -export type ButtonRootProps = ButtonProps & { - action?: 'post' | 'post_redirect' - value?: string | undefined -} - -// TODO: `fc:frame:button:$idx:action` and `fc:frame:button:$idx:target` -ButtonRoot.__type = 'button' -export function ButtonRoot({ - action = 'post', - children, - index = 0, - value, -}: ButtonRootProps) { - return [ - , - , - ] as unknown as HtmlEscapedString -} - -export type ButtonLinkProps = ButtonProps & { - href: string -} - -ButtonLink.__type = 'button' -export function ButtonLink({ children, index = 0, href }: ButtonLinkProps) { - return [ - , - , - , - ] as unknown as HtmlEscapedString -} - -export type ButtonMintProps = ButtonProps & { - target: string -} - -ButtonMint.__type = 'button' -export function ButtonMint({ children, index = 0, target }: ButtonMintProps) { - return [ - , - , - , - ] as unknown as HtmlEscapedString -} - -export type ButtonResetProps = ButtonProps - -ButtonReset.__type = 'button' -export function ButtonReset({ children, index = 0 }: ButtonResetProps) { - return ( - - ) -} - -export const Button = Object.assign(ButtonRoot, { - Link: ButtonLink, - Mint: ButtonMint, - Reset: ButtonReset, -}) - -export type TextInputProps = { - placeholder?: string | undefined -} - -TextInput.__type = 'text-input' -export function TextInput({ placeholder }: TextInputProps) { - return -} - -//////////////////////////////////////////////////////////////////////// -// Utilities -//////////////////////////////////////////////////////////////////////// - -function getIntentState( - frameData: FrameData | undefined, - intents: readonly JSXNode[] | null, -) { - const { buttonIndex, inputText } = frameData || {} - const state = { buttonIndex, buttonValue: undefined, inputText, reset: false } - if (!intents) return state - if (buttonIndex) { - const buttonIntents = intents.filter((intent) => - intent?.props.property.match(/fc:frame:button:\d$/), - ) - const intent = buttonIntents[buttonIndex - 1] - state.buttonValue = intent.props['data-value'] - if (intent.props['data-type'] === 'reset') state.reset = true - } - return state -} - -type Counter = { button: number } - -async function getFrameContext( - ctx: Context, - previousFrameContext: PreviousFrameContext | undefined, -): Promise { - const { req } = ctx - const { trustedData, untrustedData } = - (await req.json().catch(() => {})) || {} - - const { buttonIndex, buttonValue, inputText, reset } = getIntentState( - // TODO: derive from untrusted data. - untrustedData, - previousFrameContext?.intents || [], - ) - - const status = (() => { - if (reset) return 'initial' - if (req.method === 'POST') return 'response' - return 'initial' - })() - - return { - buttonIndex, - buttonValue, - inputText, - request: req, - status, - trustedData, - untrustedData, - url: toBaseUrl(req.url), - } -} - -function parseIntents(intents_: Intents) { - const nodes = intents_ as unknown as JSXNode | JSXNode[] - const counter: Counter = { - button: 1, - } - - const intents = (() => { - if (Array.isArray(nodes)) - return nodes.map((e) => parseIntent(e as JSXNode, counter)) - if (typeof nodes.children[0] === 'object') - return Object.assign(nodes, { - children: nodes.children.map((e) => parseIntent(e as JSXNode, counter)), - }) - return parseIntent(nodes, counter) - })() - - return (Array.isArray(intents) ? intents : [intents]).flat() -} - -function parseIntent(node_: JSXNode, counter: Counter) { - // Check if the node is a "falsy" node (ie. `null`, `undefined`, `false`, etc). - const node = ( - !node_ ? { children: [], props: {}, tag() {} } : node_ - ) as JSXNode - - const props = (() => { - if ((node.tag as any).__type === 'button') - return { ...node.props, children: node.children, index: counter.button++ } - if ((node.tag as any).__type === 'text-input') - return { ...node.props, children: node.children } - return {} - })() - - return (typeof node.tag === 'function' ? node.tag(props) : node) as JSXNode -} - -function toBaseUrl(path_: string) { - let path = path_.split('?')[0] - if (path.endsWith('/')) path = path.slice(0, -1) - return path -} - -function deserializeJson(data = ''): returnType { - if (data === 'undefined') return {} as returnType - return JSON.parse(decompressFromEncodedURIComponent(data)) -} - -function serializeJson(data: unknown = {}) { - return compressToEncodedURIComponent(JSON.stringify(data)) -} - -export function htmlToMetaTags(html: string, selector: string) { - const window = new Window() - window.document.write(html) - const document = window.document - return document.querySelectorAll( - selector, - ) as unknown as readonly HTMLMetaElement[] -} - -export function parseFrameProperties(metaTags: readonly HTMLMetaElement[]) { - const validPropertyNames = new Set([ - 'fc:frame', - 'fc:frame:image', - 'fc:frame:input:text', - 'fc:frame:post_url', - 'og:image', - 'og:title', - ]) - - const properties: Partial> = {} - for (const metaTag of metaTags) { - const property = metaTag.getAttribute( - 'property', - ) as FrameMetaTagPropertyName | null - if (!property) continue - - const content = metaTag.getAttribute('content') ?? '' - if (!validPropertyNames.has(property)) continue - properties[property] = content - } - - const text = properties['fc:frame:input:text'] ?? '' - const input = properties['fc:frame:input:text'] ? { text } : undefined - - const image = properties['og:image'] ?? '' - const imageAspectRatio = - (properties['fc:frame:image:aspect_ratio'] as FrameImageAspectRatio) ?? - '1.91:1' - const imageUrl = properties['fc:frame:image'] ?? '' - const postUrl = properties['fc:frame:post_url'] ?? '' - const title = properties['og:title'] ?? '' - const version = (properties['fc:frame'] as FrameVersion) ?? 'vNext' - - return { - image, - imageAspectRatio, - imageUrl, - input, - postUrl, - title, - version, - } -} - -export function parseFrameButtons(metaTags: readonly HTMLMetaElement[]) { - // https://regexr.com/7rlm0 - const buttonRegex = /fc:frame:button:(1|2|3|4)(?::(action|target))?$/ - - let currentButtonIndex = 0 - let buttonsAreMissing = false - let buttonsAreOutOfOrder = false - const buttonMap = new Map>() - const buttonActionMap = new Map() - const buttonTargetMap = new Map() - const invalidButtons: FrameButton['index'][] = [] - - for (const metaTag of metaTags) { - const property = metaTag.getAttribute( - 'property', - ) as FrameMetaTagPropertyName | null - if (!property) continue - - if (!buttonRegex.test(property)) continue - const matchArray = property.match(buttonRegex) as [ - string, - string, - string | undefined, - ] - const index = parseInt(matchArray[1], 10) as FrameButton['index'] - const type = matchArray[2] - - const content = metaTag.getAttribute('content') ?? '' - if (type === 'action') - buttonActionMap.set(index, content as FrameButton['type']) - else if (type === 'target') - buttonTargetMap.set(index, content as FrameButton['target']) - else { - if (currentButtonIndex >= index) buttonsAreOutOfOrder = true - if (currentButtonIndex + 1 === index) currentButtonIndex = index - else buttonsAreMissing = true - - if (buttonsAreOutOfOrder || buttonsAreMissing) invalidButtons.push(index) - - const title = content ?? index - buttonMap.set(index, { index, title }) - } - } - - const buttons: FrameButton[] = [] - for (const [index, button] of buttonMap) { - const type = buttonActionMap.get(index) ?? 'post' - const target = buttonTargetMap.get(index) as FrameButton['target'] - buttons.push({ - ...button, - ...(target ? { target } : {}), - type, - } as FrameButton) - } - - return buttons.toSorted((a, b) => a.index - b.index) -} - -export function validateFrameButtons(buttons: readonly FrameButton[]) { - let buttonsAreOutOfOrder = false - const invalidButtons: FrameButton['index'][] = [] - for (let i = 0; i < buttons.length; i++) { - const button = buttons[i] - const previousButton = buttons[i - 1] - - const isOutOfOrder = button.index < previousButton?.index ?? 0 - const isButtonMissing = button.index !== i + 1 - if (isOutOfOrder || isButtonMissing) buttonsAreOutOfOrder = true - - // TODO: `invalidButtons` - // link must have target in format - // mint must have target in format - } - return { buttonsAreOutOfOrder, invalidButtons } -} - -function htmlToState(html: string) { - const metaTags = htmlToMetaTags(html, 'meta[property^="farc:"]') - - const properties: Partial> = {} - for (const metaTag of metaTags) { - const property = metaTag.getAttribute( - 'property', - ) as FarcMetaTagPropertyName | null - if (!property) continue - - const content = metaTag.getAttribute('content') ?? '' - properties[property] = content - } - - return { - context: deserializeJson(properties['farc:context']), - previousContext: deserializeJson( - properties['farc:prev_context'], - ), - } -} - -function htmlToFrame(html: string) { - const metaTags = htmlToMetaTags( - html, - 'meta[property^="fc:"], meta[property^="og:"]', - ) - const properties = parseFrameProperties(metaTags) - const buttons = parseFrameButtons(metaTags) - - const fallbackImageToUrl = !properties.imageUrl - const postUrlTooLong = properties.postUrl.length > 256 - const inputTextTooLong = properties.input?.text - ? properties.input.text.length > 32 - : false - - const { buttonsAreOutOfOrder, invalidButtons } = validateFrameButtons(buttons) - - // TODO: Figure out how this is determined - // https://warpcast.com/~/developers/frames - const valid = !( - postUrlTooLong || - inputTextTooLong || - Boolean(invalidButtons.length) - ) - - const frame = { - buttons, - imageAspectRatio: properties.imageAspectRatio, - imageUrl: properties.imageUrl, - input: properties.input, - postUrl: properties.postUrl, - version: properties.version, - } - return { - ...frame, - debug: { - ...frame, - buttonsAreOutOfOrder, - fallbackImageToUrl, - htmlTags: metaTags.map((x) => x.outerHTML), - image: properties.image, - inputTextTooLong, - invalidButtons, - postUrlTooLong, - valid, - }, - title: properties.title, - } satisfies Frame -} diff --git a/src/preview.tsx b/src/preview/components.tsx similarity index 98% rename from src/preview.tsx rename to src/preview/components.tsx index 46b01bf0..55f52a6d 100644 --- a/src/preview.tsx +++ b/src/preview/components.tsx @@ -1,12 +1,7 @@ import { codeToHtml } from 'shiki' -import { - type Frame as FrameType, - type FrameButton, - type FrameContext, - type FrameInput, - type PreviousFrameContext, -} from './types.js' +import type { FrameContext, PreviousFrameContext } from '../types.js' +import type { Frame as FrameType, FrameButton, FrameInput } from './types.js' export type PreviewProps = { baseUrl: string diff --git a/src/preview/types.ts b/src/preview/types.ts new file mode 100644 index 00000000..b721c878 --- /dev/null +++ b/src/preview/types.ts @@ -0,0 +1,63 @@ +// TODO: TSDoc + +import type { FrameImageAspectRatio, FrameVersion, Pretty } from '../types.js' + +export type Frame = { + buttons?: readonly FrameButton[] | undefined + debug?: FrameDebug | undefined + imageAspectRatio: FrameImageAspectRatio + imageUrl: string + input?: FrameInput | undefined + postUrl: string + title: string + version: FrameVersion +} + +export type FrameDebug = Pretty< + Omit & { + buttonsAreOutOfOrder: boolean + fallbackImageToUrl: boolean + htmlTags: readonly string[] + image: string + imageAspectRatio: FrameImageAspectRatio + inputTextTooLong: boolean + invalidButtons: readonly FrameButton['index'][] + postUrl: string + postUrlTooLong: boolean + valid: boolean + } +> + +export type FrameButton = { + index: 1 | 2 | 3 | 4 + title: string +} & ( + | { type: 'link'; target: `http://${string}` | `https://${string}` } + | { + type: 'mint' + // TODO: tighten type + target: `eip155:${string}` + } + | { + type: 'post' | 'post_redirect' + target?: `http://${string}` | `https://${string}` | undefined + } +) + +export type FrameInput = { + text: string +} + +export type FrameMetaTagPropertyName = + | 'fc:frame' + | 'fc:frame:image' + | 'fc:frame:image:aspect_ratio' + | 'fc:frame:input:text' + | 'fc:frame:post_url' + | 'og:image' + | 'og:title' + | `fc:frame:button:${FrameButton['index']}:action` + | `fc:frame:button:${FrameButton['index']}:target` + | `fc:frame:button:${FrameButton['index']}` + +export type FarcMetaTagPropertyName = 'farc:context' | 'farc:prev_context' diff --git a/src/index.test.ts b/src/preview/utils.test.ts similarity index 99% rename from src/index.test.ts rename to src/preview/utils.test.ts index f2881228..3a6056a6 100644 --- a/src/index.test.ts +++ b/src/preview/utils.test.ts @@ -5,7 +5,7 @@ import { parseFrameButtons, parseFrameProperties, validateFrameButtons, -} from './index.tsx' +} from './utils.js' const html = ` diff --git a/src/preview/utils.ts b/src/preview/utils.ts new file mode 100644 index 00000000..d1276528 --- /dev/null +++ b/src/preview/utils.ts @@ -0,0 +1,215 @@ +import { Window } from 'happy-dom' +import type { + FrameContext, + FrameImageAspectRatio, + FrameVersion, + PreviousFrameContext, +} from '../types.js' +import { deserializeJson } from '../utils/deserializeJson.js' +import type { + FarcMetaTagPropertyName, + Frame, + FrameButton, + FrameMetaTagPropertyName, +} from './types.js' + +export function htmlToMetaTags(html: string, selector: string) { + const window = new Window() + window.document.write(html) + const document = window.document + return document.querySelectorAll( + selector, + ) as unknown as readonly HTMLMetaElement[] +} + +export function parseFrameProperties(metaTags: readonly HTMLMetaElement[]) { + const validPropertyNames = new Set([ + 'fc:frame', + 'fc:frame:image', + 'fc:frame:input:text', + 'fc:frame:post_url', + 'og:image', + 'og:title', + ]) + + const properties: Partial> = {} + for (const metaTag of metaTags) { + const property = metaTag.getAttribute( + 'property', + ) as FrameMetaTagPropertyName | null + if (!property) continue + + const content = metaTag.getAttribute('content') ?? '' + if (!validPropertyNames.has(property)) continue + properties[property] = content + } + + const text = properties['fc:frame:input:text'] ?? '' + const input = properties['fc:frame:input:text'] ? { text } : undefined + + const image = properties['og:image'] ?? '' + const imageAspectRatio = + (properties['fc:frame:image:aspect_ratio'] as FrameImageAspectRatio) ?? + '1.91:1' + const imageUrl = properties['fc:frame:image'] ?? '' + const postUrl = properties['fc:frame:post_url'] ?? '' + const title = properties['og:title'] ?? '' + const version = (properties['fc:frame'] as FrameVersion) ?? 'vNext' + + return { + image, + imageAspectRatio, + imageUrl, + input, + postUrl, + title, + version, + } +} + +export function parseFrameButtons(metaTags: readonly HTMLMetaElement[]) { + // https://regexr.com/7rlm0 + const buttonRegex = /fc:frame:button:(1|2|3|4)(?::(action|target))?$/ + + let currentButtonIndex = 0 + let buttonsAreMissing = false + let buttonsAreOutOfOrder = false + const buttonMap = new Map>() + const buttonActionMap = new Map() + const buttonTargetMap = new Map() + const invalidButtons: FrameButton['index'][] = [] + + for (const metaTag of metaTags) { + const property = metaTag.getAttribute( + 'property', + ) as FrameMetaTagPropertyName | null + if (!property) continue + + if (!buttonRegex.test(property)) continue + const matchArray = property.match(buttonRegex) as [ + string, + string, + string | undefined, + ] + const index = parseInt(matchArray[1], 10) as FrameButton['index'] + const type = matchArray[2] + + const content = metaTag.getAttribute('content') ?? '' + if (type === 'action') + buttonActionMap.set(index, content as FrameButton['type']) + else if (type === 'target') + buttonTargetMap.set(index, content as FrameButton['target']) + else { + if (currentButtonIndex >= index) buttonsAreOutOfOrder = true + if (currentButtonIndex + 1 === index) currentButtonIndex = index + else buttonsAreMissing = true + + if (buttonsAreOutOfOrder || buttonsAreMissing) invalidButtons.push(index) + + const title = content ?? index + buttonMap.set(index, { index, title }) + } + } + + const buttons: FrameButton[] = [] + for (const [index, button] of buttonMap) { + const type = buttonActionMap.get(index) ?? 'post' + const target = buttonTargetMap.get(index) as FrameButton['target'] + buttons.push({ + ...button, + ...(target ? { target } : {}), + type, + } as FrameButton) + } + + return buttons.toSorted((a, b) => a.index - b.index) +} + +export function validateFrameButtons(buttons: readonly FrameButton[]) { + let buttonsAreOutOfOrder = false + const invalidButtons: FrameButton['index'][] = [] + for (let i = 0; i < buttons.length; i++) { + const button = buttons[i] + const previousButton = buttons[i - 1] + + const isOutOfOrder = button.index < previousButton?.index ?? 0 + const isButtonMissing = button.index !== i + 1 + if (isOutOfOrder || isButtonMissing) buttonsAreOutOfOrder = true + + // TODO: `invalidButtons` + // link must have target in format + // mint must have target in format + } + return { buttonsAreOutOfOrder, invalidButtons } +} + +export function htmlToState(html: string) { + const metaTags = htmlToMetaTags(html, 'meta[property^="farc:"]') + + const properties: Partial> = {} + for (const metaTag of metaTags) { + const property = metaTag.getAttribute( + 'property', + ) as FarcMetaTagPropertyName | null + if (!property) continue + + const content = metaTag.getAttribute('content') ?? '' + properties[property] = content + } + + return { + context: deserializeJson(properties['farc:context']), + previousContext: deserializeJson( + properties['farc:prev_context'], + ), + } +} + +export function htmlToFrame(html: string) { + const metaTags = htmlToMetaTags( + html, + 'meta[property^="fc:"], meta[property^="og:"]', + ) + const properties = parseFrameProperties(metaTags) + const buttons = parseFrameButtons(metaTags) + + const fallbackImageToUrl = !properties.imageUrl + const postUrlTooLong = properties.postUrl.length > 256 + const inputTextTooLong = properties.input?.text + ? properties.input.text.length > 32 + : false + + const { buttonsAreOutOfOrder, invalidButtons } = validateFrameButtons(buttons) + + // TODO: Figure out how this is determined + // https://warpcast.com/~/developers/frames + const valid = !( + postUrlTooLong || + inputTextTooLong || + Boolean(invalidButtons.length) + ) + + const frame = { + buttons, + imageAspectRatio: properties.imageAspectRatio, + imageUrl: properties.imageUrl, + input: properties.input, + postUrl: properties.postUrl, + version: properties.version, + } + return { + ...frame, + debug: { + ...frame, + buttonsAreOutOfOrder, + fallbackImageToUrl, + htmlTags: metaTags.map((x) => x.outerHTML), + image: properties.image, + inputTextTooLong, + invalidButtons, + postUrlTooLong, + valid, + }, + title: properties.title, + } satisfies Frame +} diff --git a/src/types.ts b/src/types.ts index 65f34a81..66b6ba16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,14 +24,8 @@ export type PreviousFrameContext = FrameContext & { intents: readonly JSXNode[] } -export type TrustedData = { - messageBytes: string -} - -export type UntrustedData = FrameData - export type FrameData = { - buttonIndex?: FrameButton['index'] | undefined + buttonIndex?: 1 | 2 | 3 | 4 | undefined castId: { fid: number; hash: string } fid: number inputText?: string | undefined @@ -41,68 +35,17 @@ export type FrameData = { url: string } -export type Frame = { - buttons?: readonly FrameButton[] | undefined - debug?: FrameDebug | undefined - imageAspectRatio: FrameImageAspectRatio - imageUrl: string - input?: FrameInput | undefined - postUrl: string - title: string - version: FrameVersion -} - -export type FrameDebug = Pretty< - Omit & { - buttonsAreOutOfOrder: boolean - fallbackImageToUrl: boolean - htmlTags: readonly string[] - image: string - imageAspectRatio: FrameImageAspectRatio - inputTextTooLong: boolean - invalidButtons: readonly FrameButton['index'][] - postUrl: string - postUrlTooLong: boolean - valid: boolean - } -> - -export type FrameButton = { - index: 1 | 2 | 3 | 4 - title: string -} & ( - | { type: 'link'; target: `http://${string}` | `https://${string}` } - | { - type: 'mint' - // TODO: tighten type - target: `eip155:${string}` - } - | { - type: 'post' | 'post_redirect' - target?: `http://${string}` | `https://${string}` | undefined - } -) - -export type FrameInput = { - text: string -} - export type FrameImageAspectRatio = '1.91:1' | '1:1' export type FrameVersion = 'vNext' -export type FrameMetaTagPropertyName = - | 'fc:frame' - | 'fc:frame:image' - | 'fc:frame:image:aspect_ratio' - | 'fc:frame:input:text' - | 'fc:frame:post_url' - | 'og:image' - | 'og:title' - | `fc:frame:button:${FrameButton['index']}:action` - | `fc:frame:button:${FrameButton['index']}:target` - | `fc:frame:button:${FrameButton['index']}` +export type FrameIntent = JSX.Element | false | null | undefined +export type FrameIntents = FrameIntent | FrameIntent[] + +export type TrustedData = { + messageBytes: string +} -export type FarcMetaTagPropertyName = 'farc:context' | 'farc:prev_context' +export type UntrustedData = FrameData -type Pretty = { [key in keyof type]: type[key] } & unknown +export type Pretty = { [key in keyof type]: type[key] } & unknown diff --git a/src/utils/deserializeJson.ts b/src/utils/deserializeJson.ts new file mode 100644 index 00000000..aee1caff --- /dev/null +++ b/src/utils/deserializeJson.ts @@ -0,0 +1,6 @@ +import { decompressFromEncodedURIComponent } from 'lz-string' + +export function deserializeJson(data = ''): returnType { + if (data === 'undefined') return {} as returnType + return JSON.parse(decompressFromEncodedURIComponent(data)) +} diff --git a/src/utils/getFrameContext.ts b/src/utils/getFrameContext.ts new file mode 100644 index 00000000..37c31195 --- /dev/null +++ b/src/utils/getFrameContext.ts @@ -0,0 +1,37 @@ +import type { Context } from 'hono' + +import type { FrameContext, PreviousFrameContext } from '../types.js' +import { getIntentState } from './getIntentState.js' +import { toBaseUrl } from './toBaseUrl.js' + +export async function getFrameContext( + ctx: Context, + previousFrameContext: PreviousFrameContext | undefined, +): Promise { + const { req } = ctx + const { trustedData, untrustedData } = + (await req.json().catch(() => {})) || {} + + const { buttonIndex, buttonValue, inputText, reset } = getIntentState( + // TODO: derive from untrusted data. + untrustedData, + previousFrameContext?.intents || [], + ) + + const status = (() => { + if (reset) return 'initial' + if (req.method === 'POST') return 'response' + return 'initial' + })() + + return { + buttonIndex, + buttonValue, + inputText, + request: req, + status, + trustedData, + untrustedData, + url: toBaseUrl(req.url), + } +} diff --git a/src/utils/getIntentState.ts b/src/utils/getIntentState.ts new file mode 100644 index 00000000..a5c931d7 --- /dev/null +++ b/src/utils/getIntentState.ts @@ -0,0 +1,20 @@ +import type { JSXNode } from 'hono/jsx' +import type { FrameData } from '../types.js' + +export function getIntentState( + frameData: FrameData | undefined, + intents: readonly JSXNode[] | null, +) { + const { buttonIndex, inputText } = frameData || {} + const state = { buttonIndex, buttonValue: undefined, inputText, reset: false } + if (!intents) return state + if (buttonIndex) { + const buttonIntents = intents.filter((intent) => + intent?.props.property.match(/fc:frame:button:\d$/), + ) + const intent = buttonIntents[buttonIndex - 1] + state.buttonValue = intent.props['data-value'] + if (intent.props['data-type'] === 'reset') state.reset = true + } + return state +} diff --git a/src/utils/parseIntents.ts b/src/utils/parseIntents.ts new file mode 100644 index 00000000..5f22379d --- /dev/null +++ b/src/utils/parseIntents.ts @@ -0,0 +1,41 @@ +import type { JSXNode } from 'hono/jsx' + +import type { FrameIntents } from '../types.js' + +type Counter = { button: number } + +export function parseIntents(intents_: FrameIntents) { + const nodes = intents_ as unknown as JSXNode | JSXNode[] + const counter: Counter = { + button: 1, + } + + const intents = (() => { + if (Array.isArray(nodes)) + return nodes.map((e) => parseIntent(e as JSXNode, counter)) + if (typeof nodes.children[0] === 'object') + return Object.assign(nodes, { + children: nodes.children.map((e) => parseIntent(e as JSXNode, counter)), + }) + return parseIntent(nodes, counter) + })() + + return (Array.isArray(intents) ? intents : [intents]).flat() +} + +function parseIntent(node_: JSXNode, counter: Counter) { + // Check if the node is a "falsy" node (ie. `null`, `undefined`, `false`, etc). + const node = ( + !node_ ? { children: [], props: {}, tag() {} } : node_ + ) as JSXNode + + const props = (() => { + if ((node.tag as any).__type === 'button') + return { ...node.props, children: node.children, index: counter.button++ } + if ((node.tag as any).__type === 'text-input') + return { ...node.props, children: node.children } + return {} + })() + + return (typeof node.tag === 'function' ? node.tag(props) : node) as JSXNode +} diff --git a/src/utils/serializeJson.ts b/src/utils/serializeJson.ts new file mode 100644 index 00000000..0c0918a3 --- /dev/null +++ b/src/utils/serializeJson.ts @@ -0,0 +1,5 @@ +import { compressToEncodedURIComponent } from 'lz-string' + +export function serializeJson(data: unknown = {}) { + return compressToEncodedURIComponent(JSON.stringify(data)) +} diff --git a/src/utils/toBaseUrl.ts b/src/utils/toBaseUrl.ts new file mode 100644 index 00000000..34b91b4c --- /dev/null +++ b/src/utils/toBaseUrl.ts @@ -0,0 +1,5 @@ +export function toBaseUrl(path_: string) { + let path = path_.split('?')[0] + if (path.endsWith('/')) path = path.slice(0, -1) + return path +} diff --git a/tsconfig.json b/tsconfig.json index fca4c3dc..4d7693fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,5 @@ { // This configuration is used for local development and type checking. "extends": "./tsconfig.base.json", - "include": ["src"], - "compilerOptions": { - "allowImportingTsExtensions": true - } + "include": ["src"] }