From 885347e6f88c20238c58ac69591be54eec15a1f8 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 5 Mar 2024 07:57:01 +1100 Subject: [PATCH 01/20] fix: next.js circular dep --- .changeset/loud-suns-juggle.md | 5 +++++ src/next/getFrameMetadata.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/loud-suns-juggle.md diff --git a/.changeset/loud-suns-juggle.md b/.changeset/loud-suns-juggle.md new file mode 100644 index 00000000..a251fc6e --- /dev/null +++ b/.changeset/loud-suns-juggle.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Fixed Next.js circular dependency on `getFrameMetadata`. diff --git a/src/next/getFrameMetadata.ts b/src/next/getFrameMetadata.ts index 3d265ce0..b579eacc 100644 --- a/src/next/getFrameMetadata.ts +++ b/src/next/getFrameMetadata.ts @@ -18,7 +18,9 @@ import { getFrameMetadata as getFrameMetadata_ } from '../utils/getFrameMetadata export async function getFrameMetadata( url: string, ): Promise> { - const metadata = await getFrameMetadata_(url) + const metadata = await getFrameMetadata_(url).catch(() => {}) + if (!metadata) return {} + const result: Record = {} for (const { property, content } of metadata) result[property] = content return result From b7bab08958a946c7cc1b52c6be476c00aa1f6355 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 07:59:00 +1100 Subject: [PATCH 02/20] chore: version packages (#70) Co-authored-by: github-actions[bot] --- .changeset/loud-suns-juggle.md | 5 ----- src/CHANGELOG.md | 6 ++++++ src/package.json | 2 +- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/loud-suns-juggle.md diff --git a/.changeset/loud-suns-juggle.md b/.changeset/loud-suns-juggle.md deleted file mode 100644 index a251fc6e..00000000 --- a/.changeset/loud-suns-juggle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": patch ---- - -Fixed Next.js circular dependency on `getFrameMetadata`. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index dd2520a3..ed3b783e 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,11 @@ # frog +## 0.2.14 + +### Patch Changes + +- [`885347e`](https://github.com/wevm/frog/commit/885347e6f88c20238c58ac69591be54eec15a1f8) Thanks [@jxom](https://github.com/jxom)! - Fixed Next.js circular dependency on `getFrameMetadata`. + ## 0.2.13 ### Patch Changes diff --git a/src/package.json b/src/package.json index d7eda880..1aa4690b 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", diff --git a/src/version.ts b/src/version.ts index 9ce772dd..71619264 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.2.13' +export const version = '0.2.14' From d68e50e69ce70183b8a7c3179701dd29809fe32d Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 5 Mar 2024 08:34:35 +1100 Subject: [PATCH 03/20] docs: move hubs section up --- site/vocs.config.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/site/vocs.config.tsx b/site/vocs.config.tsx index 58e730a0..e3f85fe1 100644 --- a/site/vocs.config.tsx +++ b/site/vocs.config.tsx @@ -325,6 +325,15 @@ export default defineConfig({ }, ], }, + { + text: 'Hubs', + items: [ + { + text: 'Neynar', + link: '/hubs/neynar', + }, + ], + }, { text: 'Intent Reference', items: [ @@ -349,15 +358,6 @@ export default defineConfig({ { text: 'Frog.hono', link: '/reference/frog-hono' }, ], }, - { - text: 'Hubs', - items: [ - { - text: 'Neynar', - link: '/hubs/neynar', - }, - ], - }, { text: 'CLI Reference', items: [ From 72ec78d7283fe479b5e9173d13397002b9a28c3d Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 5 Mar 2024 14:00:11 +1100 Subject: [PATCH 04/20] ci: prune tags action --- .github/workflows/prune-tags.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/prune-tags.yml diff --git a/.github/workflows/prune-tags.yml b/.github/workflows/prune-tags.yml new file mode 100644 index 00000000..6b43ec71 --- /dev/null +++ b/.github/workflows/prune-tags.yml @@ -0,0 +1,23 @@ +name: Prune NPM tags +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +jobs: + prune: + name: Prune NPM tags + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Setup .npmrc file + uses: actions/setup-node@v4 + with: + registry-url: 'https://registry.npmjs.org' + + - name: Prune tags + run: cd src && npm view --json | jq -r '.["dist-tags"] | to_entries | .[] | select(.key != "latest") | .key' | xargs -I % npm dist-tag rm frog % + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From bd4c7740f7497d9b146db4ca63e828af8ed448ca Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Tue, 5 Mar 2024 15:15:47 +1100 Subject: [PATCH 05/20] docs: update --- site/pages/concepts/render-cycles.mdx | 78 +++++++++++++++++++++++++++ site/vocs.config.tsx | 12 +++-- 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 site/pages/concepts/render-cycles.mdx diff --git a/site/pages/concepts/render-cycles.mdx b/site/pages/concepts/render-cycles.mdx new file mode 100644 index 00000000..7d226cba --- /dev/null +++ b/site/pages/concepts/render-cycles.mdx @@ -0,0 +1,78 @@ +# Render Cycles + +In Frog, you may see that your frame handler may get invoked twice. + +There are a maximum of two render cycles for `.frame` handlers: + +- one for the **"main"** frame endpoint to render intents & derive state, and +- one for the **"OG image"** frame endpoint to render the frame image. + +:::note[Note] +If your `image` is defined as a location (ie. a string), you will only have one render cycle (the "main" cycle). +::: + +If we want to enforce idempotency (ie. invoke something once), we can distinguish between the two cycles by using the [`cycle` property on context](/reference/frog-frame-context#cycle). + +For example, we may want to perform a side-effect to update a database: + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' +import { db } from './db' + +export const app = new Frog() + +app.frame('/', (c) => { + const { buttonValue, cycle } = c // [!code focus] + + if (cycle === 'main') db.save(buttonValue) // [!code focus] + + return c.res({ + image: ( +
+ Selected: {buttonValue} +
+ ), + intents: [ + , + , + + ] + }) +}) +``` + +:::note +The `if (cycle === 'main')` block can be seen similarly to an "effect" function in other UI frameworks/libraries (ie. `useEffect` in React, `$effect` in Svelte, etc). +::: + +It is worth noting that the `deriveState` function is idempotent by default, and it is only invoked once. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' +import { db } from './db' + +export const app = new Frog({ + initialState: { + count: 0, + number: 0 + } +}) + +app.frame('/', (c) => { + const { buttonValue, cycle, deriveState } = c // [!code focus] + + const state = deriveState((previousState) => { // [!code focus] + previousState.number = Math.random() // [!code focus] + previousState.count++ // [!code focus] + }) // [!code focus] +// @log: `state.number` will be equal between render cycles: +// @log: (cycle = "main") `state.number`: 0.270822354849678, `state.count`: 1 +// @log: (cycle = "image") `state.number`: 0.270822354849678, `state.count`: 1 +}) +``` \ No newline at end of file diff --git a/site/vocs.config.tsx b/site/vocs.config.tsx index e3f85fe1..93835ceb 100644 --- a/site/vocs.config.tsx +++ b/site/vocs.config.tsx @@ -266,14 +266,18 @@ export default defineConfig({ text: 'Routing', link: '/concepts/routing', }, - { - text: 'Connecting Frames (Actions)', - link: '/concepts/actions', - }, { text: 'Images & Intents', link: '/concepts/images-intents', }, + { + text: 'Render Cycles', + link: '/concepts/render-cycles', + }, + { + text: 'Connecting Frames (Actions)', + link: '/concepts/actions', + }, { text: 'Browser Redirects', link: '/concepts/browser-redirects', From 0bb762d4067e297f3c53824f2ec6d69eac83b0b9 Mon Sep 17 00:00:00 2001 From: jxom Date: Wed, 6 Mar 2024 07:23:29 +1100 Subject: [PATCH 06/20] feat: transactions (#63) * feat: tx spec * fix: type * wip: spec updates * tweak * tweaks * tweaks * tweak * chore: tweaks * chore: tweak * chore: tweak * tweak: real error * tweak: enrich tx context * docs: tx docs * ci: trigger * docs: Button.Transaction intent --- playground/src/index.tsx | 4 +- playground/src/transaction.tsx | 250 +++++++++++ site/pages/concepts/transactions.mdx | 370 ++++++++++++++++ site/pages/intents/button-transaction.mdx | 46 ++ .../reference/frog-transaction-context.mdx | 395 ++++++++++++++++++ .../reference/frog-transaction-response.mdx | 363 ++++++++++++++++ site/pages/reference/frog-transaction.mdx | 103 +++++ site/vocs.config.tsx | 29 +- src/components/Button.tsx | 39 +- src/dev/components/Preview.tsx | 2 +- src/dev/types.ts | 2 +- src/dev/utils/htmlToState.ts | 2 +- src/edge/index.ts | 12 +- src/frog-base.tsx | 15 +- src/frog.tsx | 7 +- src/index.ts | 17 +- src/routes/transaction.ts | 33 ++ src/types/context.ts | 111 +++++ src/types/frame.ts | 67 +-- src/types/transaction.ts | 88 ++++ src/types/utils.ts | 29 ++ src/utils/getFrameContext.ts | 30 +- src/utils/getIntentState.ts | 5 +- src/utils/getTransactionContext.ts | 97 +++++ src/utils/parseIntents.ts | 17 +- src/utils/requestToContext.ts | 17 +- 26 files changed, 2020 insertions(+), 130 deletions(-) create mode 100644 playground/src/transaction.tsx create mode 100644 site/pages/concepts/transactions.mdx create mode 100644 site/pages/intents/button-transaction.mdx create mode 100644 site/pages/reference/frog-transaction-context.mdx create mode 100644 site/pages/reference/frog-transaction-response.mdx create mode 100644 site/pages/reference/frog-transaction.mdx create mode 100644 src/routes/transaction.ts create mode 100644 src/types/context.ts create mode 100644 src/types/transaction.ts create mode 100644 src/utils/getTransactionContext.ts diff --git a/playground/src/index.tsx b/playground/src/index.tsx index 5c97826f..073ccaf2 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -3,6 +3,7 @@ import * as hubs from 'frog/hubs' import { app as routingApp } from './routing.js' import { app as todoApp } from './todos.js' +import { app as transactionApp } from './transaction.js' export const app = new Frog({ browserLocation: '/:path/dev', @@ -225,5 +226,6 @@ app.frame('/redirect-buttons', (c) => { }) }) -app.route('/todos', todoApp) app.route('/routing', routingApp) +app.route('/transaction', transactionApp) +app.route('/todos', todoApp) diff --git a/playground/src/transaction.tsx b/playground/src/transaction.tsx new file mode 100644 index 00000000..97f515bd --- /dev/null +++ b/playground/src/transaction.tsx @@ -0,0 +1,250 @@ +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', () => { + return { + image: ( +
+ Example +
+ ), + intents: [ + Raw, + Send Transaction, + Mint, + ], + } +}) + +// Raw transaction +app.transaction('/raw-send', (c) => { + return c.res({ + chainId: 'eip155:1', + method: 'eth_sendTransaction', + params: { + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: 1n, + }, + }) +}) + +// Send transaction +app.transaction('/send', (c) => { + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: 1n, + }) +}) + +// Contract transaction +app.transaction('/mint', (c) => { + return c.contract({ + abi: wagmiExampleAbi, + chainId: 'eip155:1', + functionName: 'mint', + to: '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2', + }) +}) + +///////////////////////////////////////////////////////////////////// +// Constants + +export const wagmiExampleAbi = [ + { inputs: [], stateMutability: 'nonpayable', type: 'constructor' }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'approved', + type: 'address', + }, + { + indexed: true, + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'operator', + type: 'address', + }, + { indexed: false, internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'ApprovalForAll', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'from', type: 'address' }, + { indexed: true, internalType: 'address', name: 'to', type: 'address' }, + { + indexed: true, + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'getApproved', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'address', name: 'operator', type: 'address' }, + ], + name: 'isApprovedForAll', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'mint', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'mint', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'operator', type: 'address' }, + { internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'setApprovalForAll', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes4', name: 'interfaceId', type: 'bytes4' }], + name: 'supportsInterface', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'symbol', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'tokenURI', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const diff --git a/site/pages/concepts/transactions.mdx b/site/pages/concepts/transactions.mdx new file mode 100644 index 00000000..99e85dee --- /dev/null +++ b/site/pages/concepts/transactions.mdx @@ -0,0 +1,370 @@ +# Transactions + +Farcaster Frames have the ability to instruct an App to invoke and perform Ethereum transactions (see the [spec](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4)). + +## Overview + +At a glance: + +1. A Frame has a `{:jsx}` element with a specified target `.transaction` route. +2. When the user presses the button in the App, the App will make a `POST` request to the `.transaction` route. +3. The App uses the response to forward the transaction data to the user's wallet for signing and broadcasting. +4. Once the user has sent the transaction, the App will perform a `POST` request to the frame. + +## Walkthrough + +Here is a trivial example on how to expose a transaction interface in a frame. We will break it down below. + +:::code-group + +```tsx twoslash [src/index.tsx] +// @noErrors +/** @jsxImportSource hono/jsx */ +// ---cut--- +import { Button, Frog, TextInput, parseEther } from 'frog' +import { abi } from './abi' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + action: '/finish', + image: ( +
+ Perform a transaction +
+ ), + intents: [ + , + Send Ether, + Mint, + ] + }) +}) + +app.frame('/finish', (c) => { + const { transactionId } = c + return c.res({ + image: ( +
+ Transaction ID: {transactionId} +
+ ) + }) +}) + +app.transaction('/send-ether', (c) => { + const { inputText } = c + // Send transaction response. + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther(inputText), + }) +}) + +app.transaction('/mint', (c) => { + const { inputText } = c + // Contract transaction response. + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', + args: [69420n], + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther(inputText) + }) +}) +``` + +```tsx twoslash [src/abi.ts] filename="./abi.ts" +export const abi = [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'mint', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const +``` + +::: + +::::steps + +### 1. Render Frame & Intents + +In the example above, we are rendering three transaction intents: + +1. A **text input** to capture the amount of ether to send with the transaction. +2. A **"Send Ether" button** that will call the `/send-ether` route, and expose a "send ethereum to an address" interface to the App. +3. A **"Mint" button** that will call the `/mint` route, and expose a "mint NFT" interface to the App. + +```tsx twoslash [src/index.tsx] +// @noErrors +/** @jsxImportSource hono/jsx */ +import { Button, Frog, parseEther } from 'frog' +import { abi } from './abi' + +export const app = new Frog() +// ---cut--- +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Perform a transaction +
+ ), + intents: [ + , + Send Ether, + Mint, + ] + }) +}) + +// ... +``` + + +### 2. Handle `/send-ether` Requests + +Without route handlers to handle these requests, these buttons will be meaningless. + +Firstly, let's define a `/send-ether` route to handle the "Send Ether" button: + +```tsx twoslash [src/index.tsx] +// @noErrors +/** @jsxImportSource hono/jsx */ +import { Button, Frog, parseEther } from 'frog' +import { abi } from './abi' + +export const app = new Frog() +// ---cut--- +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Perform a transaction +
+ ), + intents: [ + , + Send Ether, // [!code focus] + Mint, + ] + }) +}) + +// ... + +app.transaction('/send-ether', (c) => { // [!code focus] + const { inputText } = c // [!code focus] + // Send transaction response. // [!code focus] + return c.send({ // [!code focus] + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther(inputText), // [!code focus] + }) // [!code focus] +}) // [!code focus] +``` + +A breakdown of the `/send-ether` route handler: + +- We are responding with a `c.send` ("send transaction") response. +- We are extracting user input from the previous frame via `inputText`. +- Within `c.send`, we can specify a: + - `chainId`: CAIP-2 compliant chain ID. We are sending to `eip155:1` where `1` is Ethereum mainnet. + - `to`: a recipient. + - `value`: the amount of wei to send. We are using `parseEther` to convert ether → wei. + - `data`: optional calldata to include in the transaction. + - `abi`: optional ABI to include in the transaction. +- The `c.send` function constructs a [well-formed JSON response](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4) to be consumed by the App. + +:::tip[Tip] +We can also utilize the context to extract things like [frame data](/reference/frog-transaction-context#framedata), +[button index/value](/reference/frog-transaction-context#buttonvalue) or [input value](/reference/frog-transaction-context#inputvalue) that was interacted with, and [more](/reference/frog-transaction-context): + +```tsx twoslash +// @noErrors +/** @jsxImportSource hono/jsx */ +import { Button, Frog, parseEther } from 'frog' +import { abi } from './abi' + +export const app = new Frog() +// ---cut--- +app.transaction('/send-ether', (c) => { + const { buttonValue, inputText, frameData } = c // [!code focus] + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther(inputText), + }) +}) +``` +::: + +### 3. Handle `/mint` Requests + +Secondly, let's define a `/mint` route to handle the "Mint" button: + +:::code-group + +```tsx twoslash [src/index.tsx] +// @noErrors +/** @jsxImportSource hono/jsx */ +import { Button, Frog, parseEther } from 'frog' + +export const app = new Frog() +// ---cut--- +import { abi } from './abi' + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Perform a transaction +
+ ), + intents: [ + , + Send Ether, + Mint, // [!code focus] + ] + }) +}) + +// ... + +app.transaction('/mint', (c) => { // [!code focus] + const { inputText } = c // [!code focus] + // Contract transaction response. // [!code focus] + return c.contract({ // [!code focus] + abi, // [!code focus] + functionName: 'mint', // [!code focus] + args: [69420n], // [!code focus] + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther(inputText) // [!code focus] + }) // [!code focus] +}) // [!code focus] +``` + +```tsx twoslash [src/abi.ts] filename="./abi.ts" +export const abi = [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'mint', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const +``` + +::: + +A breakdown of the `/mint` route handler: + +- We are responding with a `c.contract` ("contract transaction") response. +- We are extracting user input from the previous frame via `inputText`. +- Within `c.contract`, we can specify a: + - `abi`: ABI for the contract. + - `functionName`: Function to call on the contract. + - `args`: Arguments to supply to the function. + - `chainId`: CAIP-2 compliant chain ID. + - `to`: Contract address. + - `value`: Optional amount of wei to send to the payable function. +- The `c.contract` function constructs a [well-formed JSON response](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4) to be consumed by the App. + +### 4. Handle Post-Transaction Execution + +Once the user has sent the transaction, the App will perform a `POST` request to the frame. + +We can extract the transaction ID from context via `c.transactionId`. + +:::note +Note that if you don't specify an [`action` on the frame](/reference/frog-frame-response#action), the App will perform a request to the same frame. +::: + +:::code-group + +```tsx twoslash [src/index.tsx] +// @noErrors +/** @jsxImportSource hono/jsx */ +import { Button, Frog, parseEther } from 'frog' +import { abi } from './abi' + +export const app = new Frog() +// ---cut--- +app.frame('/', (c) => { + return c.res({ + action: '/finish', // [!code focus] + image: ( +
+ Perform a transaction +
+ ), + intents: [ + Send Ether, + Mint, + ] + }) +}) + +app.frame('/finish', (c) => { // [!code focus] + const { transactionId } = c // [!code focus] + return c.res({ // [!code focus] + image: ( // [!code focus] +
// [!code focus] + Transaction ID: {transactionId} // [!code focus] +
// [!code focus] + ) // [!code focus] + }) // [!code focus] +}) // [!code focus] + +app.transaction('/send-ether', (c) => { + // Send transaction response. + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther('1'), + }) +}) + +app.transaction('/mint', (c) => { + // Contract transaction response. + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', + args: [69420n], + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B' + }) +}) +``` + +```tsx twoslash [src/abi.ts] filename="./abi.ts" +export const abi = [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'mint', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const +``` + +::: + +### 5. Bonus: Learn the API + +You can learn more about the transaction APIs here: + +- [`Frog.transaction` Reference](/reference/frog-transaction) +- [`Frog.transaction` Context Reference](/reference/frog-transaction-context) +- [`Frog.transaction` Response Reference](/reference/frog-transaction-response) + +:::: \ No newline at end of file diff --git a/site/pages/intents/button-transaction.mdx b/site/pages/intents/button-transaction.mdx new file mode 100644 index 00000000..f7ec770d --- /dev/null +++ b/site/pages/intents/button-transaction.mdx @@ -0,0 +1,46 @@ +# Button.Transaction + +## Import + +```tsx twoslash +import { Button } from 'frog' +``` + +## Usage + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Perform a transaction +
+ ), + intents: [ + , + Mint, // [!code focus] + ] + }) +}) +``` + +## Props + +### target + +- **Type:** `string` + +The target `.transaction` endpoint. + +### value (optional) + +- **Type:** `string` + +The value of the button. Casts to the `buttonValue` context property when clicked. diff --git a/site/pages/reference/frog-transaction-context.mdx b/site/pages/reference/frog-transaction-context.mdx new file mode 100644 index 00000000..8953be90 --- /dev/null +++ b/site/pages/reference/frog-transaction-context.mdx @@ -0,0 +1,395 @@ +# Frog.transaction Context + +The `c` object is the parameter of the route handlers. It contains context for the transaction route. + +```tsx twoslash +// @noErrors +import { Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { // [!code focus] + return c.send({/* ... */}) +}) +``` + +## buttonIndex + +- **Type**: `number` + +The index of the button that was previously clicked. + +For example, if the user clicked `"Banana"`, then `buttonIndex` will be `2`. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Select a fruit. +
+ ), + intents: [ + // [!code focus] + Apple // [!code focus] + , // [!code focus] + // [!code focus] + Banana // [!code focus] + , // [!code focus] + ] + }) +}) + +app.transaction('/send-ether', (c) => { + const { buttonIndex } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## buttonValue + +- **Type**: `string` + +The value of the button that was previously clicked. + +For example, if the user clicked `"Banana"`, then `buttonValue` will be `"banana"`. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Select a fruit. +
+ ), + intents: [ + // [!code focus] + Apple // [!code focus] + , // [!code focus] + // [!code focus] + Banana // [!code focus] + , // [!code focus] + ] + }) +}) + +app.transaction('/send-ether', (c) => { + const { buttonValue } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## contract + +- **Type**: `TransactionResponseFn` + +Contract transaction response. [See more.](/reference/frog-transaction-response#contract-transaction-ccontract) + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({/* ... */}) // [!code focus] +}) +``` + +## frameData + +- **Type**: `FrameData` + +Data from the frame that was passed via the `POST` body from a Farcaster Client. [See more.](https://docs.farcaster.xyz/reference/frames/spec#frame-signature-packet) + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + const { frameData } = c + const { castId, fid, messageHash, network, timestamp, url } = frameData // [!code focus] + return c.send({/* ... */}) +}) +``` + +## initialPath + +- **Type**: `string` + +Initial/start path of the frame set. + +If the user clicks `{:jsx}`, they will be directed to this URL. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + const { initialPath } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## inputText + +- **Type**: `string` + +The value of the input that was previously interacted with. + +For example, if the user typed `"Banana"`, then `inputText` will be `"Banana"`. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog, TextInput } from 'frog' + +export const app = new Frog() + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Send Ether +
+ ), + intents: [ + , // [!code focus] + , + ] + }) +}) + +app.transaction('/send-ether', (c) => { + const { inputText } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## 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() + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ Select your fruit +
+ ), + intents: [ + // [!code focus] + Apple // [!code focus] + , // [!code focus] + // [!code focus] + Banana // [!code focus] + , // [!code focus] + ] + }) +}) + +app.transaction('/send-ether', (c) => { // [!code focus] + const { buttonValue, previousButtonValues } = c + console.log(previousButtonValues) // [!code focus] +// @log: ['apple', 'banana'] + return c.send({/* ... */}) +}) +``` + +## 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({ + initialState: { + values: [] + } +}) + +app.frame('/', (c) => { + const { buttonValue, deriveState } = c + const state = deriveState(previousState => { + if (buttonValue) previousState.values.push(buttonValue) + }) + return c.res({ + image: ( +
+ {values.map(value =>
{value}
)} +
+ ), + intents: [ + , + , + Send, + ] + }) +}) + +app.transaction('/send-ether', (c) => { + const { previousState } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## 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.transaction('/send-ether', (c) => { + const { req } = c // [!code focus] + return c.res({/* ... */}) +}) +``` + +## res + +- **Type**: `TransactionResponseFn` + +Raw transaction response. [See more.](/reference/frog-transaction-response#raw-transaction-craw) + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.res({/* ... */}) // [!code focus] +}) +``` + +## send + +- **Type**: `TransactionResponseFn` + +Send transaction response. [See more.](/reference/frog-transaction-response#send-transaction-csend) + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({/* ... */}) // [!code focus] +}) +``` + +## verified + +- **Type**: `boolean` + +Whether or not the [`frameData`](#framedata) (and [`buttonIndex`](#buttonindex), [`buttonValue`](#buttonvalue), [`inputText`](#inputtext)) was verified by the Farcaster Hub API. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + const { verified } = c // [!code focus] + return c.send({/* ... */}) +}) +``` + +## url + +- **Type**: `string` + +Current URL. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + const { url } = c // [!code focus] + return c.send({/* ... */}) +}) +``` \ No newline at end of file diff --git a/site/pages/reference/frog-transaction-response.mdx b/site/pages/reference/frog-transaction-response.mdx new file mode 100644 index 00000000..078bb08b --- /dev/null +++ b/site/pages/reference/frog-transaction-response.mdx @@ -0,0 +1,363 @@ +# Frog.transaction Response + +The response returned from the `.transaction` handler. + +There are three types of responses: + +- [Send Transaction (`c.send`)](#send-transaction-csend): Convinience method to **send a transaction**. +- [Contract Transaction (`c.contract`)](#contract-transaction-ccontract): Convinience method to **invoke a contract function** (with inferred types & automatic encoding). +- [Raw Transaction (`c.raw`)](#raw-transaction-craw): Low-level method to **send raw transaction** (mimics the [Transaction Spec API](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4)). + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ // [!code focus] + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther('1'), // [!code focus] + }) // [!code focus] +}) + +app.transaction('/mint', (c) => { + return c.contract({ // [!code focus] + abi, // [!code focus] + chainId: 'eip155:1', // [!code focus] + functionName: 'mint', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + }) // [!code focus] +}) + +app.transaction('/raw-send', (c) => { + return c.res({ // [!code focus] + chainId: 'eip155:1', // [!code focus] + method: 'eth_sendTransaction', // [!code focus] + params: { // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: 1n, // [!code focus] + }, // [!code focus] + }) // [!code focus] +}) +``` + +## Send Transaction (`c.send`) + +### chainId + +- **Type:** `"eip155:${number}"` + +A CAIP-2 Chain ID to identify the transaction network. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther('1') + }) +}) +``` + +### to + +- **Type:** `Address` + +Transaction recipient. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther('1') + }) +}) +``` + +### value + +- **Type:** `Address` + +Value (in wei) to send with the transaction. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther('1') // [!code focus] + }) +}) +``` + +### abi (optional) + +- **Type:** `Abi` + +The ABI of the contract. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + abi, // [!code focus] + }) +}) +``` + +### data (optional) + +- **Type:** `Hex` + +Calldata to send with the transaction + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + data: '0xdeadbeef', // [!code focus] + }) +}) +``` + +## Contract Transaction (`c.contract`) + +### abi + +- **Type:** `Abi` + +The ABI of the contract. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, // [!code focus] + chainId: 'eip155:1', + functionName: 'mint', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + }) +}) +``` + +### chainId + +- **Type:** `"eip155:${number}"` + +A CAIP-2 Chain ID to identify the transaction network. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, + chainId: 'eip155:1', // [!code focus] + functionName: 'mint', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + }) +}) +``` + +### functionName + +- **Type:** `string` + +Function to invoke on the contract. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + }) +}) +``` + +### args + +- **Type:** `unknown` + +Args to pass to the contract function. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', + args: [69420n], // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + }) +}) +``` + +### to + +- **Type:** `Address` + +The address of the contract. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', + args: [69420n], + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + }) +}) +``` + +### value (optional) + +- **Type:** `Address` + +Value to send with the transaction. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/mint', (c) => { + return c.contract({ + abi, + chainId: 'eip155:1', + functionName: 'mint', + args: [69420n], + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther('1'), // [!code focus] + }) +}) +``` + +## Raw Transaction (`c.raw`) + +### chainId + +- **Type:** `"eip155:${number}"` + +A CAIP-2 Chain ID to identify the transaction network. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/raw-send', (c) => { + return c.res({ + chainId: 'eip155:1', // [!code focus] + method: 'eth_sendTransaction', + params: { + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: 1n, + }, + }) +}) +``` + +### method + +- **Type:** `"eth_sendTransaction"` + +A method ID to identify the type of transaction request. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/raw-send', (c) => { + return c.res({ + chainId: 'eip155:1', + method: 'eth_sendTransaction', // [!code focus] + params: { + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: 1n, + }, + }) +}) +``` + +### params + +- **Type:** `EthSendTransactionParameters` + +Transaction parameters + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/raw-send', (c) => { + return c.res({ + chainId: 'eip155:1', + method: 'eth_sendTransaction', + params: { // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: 1n, // [!code focus] + }, // [!code focus] + }) +}) +``` \ No newline at end of file diff --git a/site/pages/reference/frog-transaction.mdx b/site/pages/reference/frog-transaction.mdx new file mode 100644 index 00000000..9d665d8f --- /dev/null +++ b/site/pages/reference/frog-transaction.mdx @@ -0,0 +1,103 @@ +# Frog.transaction + +## Import + +```tsx twoslash +import { Frog } from 'frog' +``` + +## Usage + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog, parseEther } from 'frog' + +const app = new Frog() + +app.transaction('/send-ether', (c) => { // [!code focus] + // Send transaction response. // [!code focus] + return c.send({ // [!code focus] + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther('1'), // [!code focus] + }) // [!code focus] +}) // [!code focus] + +app.transaction('/mint', (c) => { // [!code focus] + // Contract transaction response. // [!code focus] + return c.contract({ // [!code focus] + abi, // [!code focus] + chainId: 'eip155:1', // [!code focus] + functionName: 'mint', // [!code focus] + args: [69420n], // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B' // [!code focus] + }) // [!code focus] +}) // [!code focus] +``` + +## Parameters + +### path + +- **Type:** `string` + +Path of the route. + +[Read more.](/concepts/routing) + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog, parseEther } from 'frog' + +const app = new Frog() + +app.transaction( + '/send-ether', // [!code focus] + (c) => { + // Send transaction response. + return c.send({ + chainId: 'eip155:1', + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + value: parseEther('1'), + }) + } +) +``` + +### handler + +- **Type:** `(c: Context) => FrameResponse` + +Handler function for the route. + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog, parseEther } from 'frog' + +const app = new Frog() + +app.transaction( + '/send-ether', + (c) => { // [!code focus] + return c.send({ // [!code focus] + chainId: 'eip155:1', // [!code focus] + to: '0xd2135CfB216b74109775236E36d4b433F1DF507B', // [!code focus] + value: parseEther('1'), // [!code focus] + }) // [!code focus] + } // [!code focus] +) +``` + +#### Context Parameter + +[See the Context API.](/reference/frog-transaction-context) + +#### Transaction Response + +[See the Transaction Response API.](/reference/frog-transaction-response) diff --git a/site/vocs.config.tsx b/site/vocs.config.tsx index 93835ceb..ce501d70 100644 --- a/site/vocs.config.tsx +++ b/site/vocs.config.tsx @@ -302,6 +302,10 @@ export default defineConfig({ text: 'Middleware', link: '/concepts/middleware', }, + { + text: 'Transactions', + link: '/concepts/transactions', + }, ], }, { @@ -346,6 +350,7 @@ export default defineConfig({ { text: 'Button.Mint', link: '/intents/button-mint' }, { text: 'Button.Redirect', link: '/intents/button-redirect' }, { text: 'Button.Reset', link: '/intents/button-reset' }, + { text: 'Button.Transaction', link: '/intents/button-transaction' }, { text: 'TextInput', link: '/intents/textinput' }, ], }, @@ -353,11 +358,27 @@ export default defineConfig({ text: 'Frog Reference', items: [ { text: 'Frog', link: '/reference/frog' }, - { text: 'Frog.frame', link: '/reference/frog-frame' }, - { text: 'Frog.frame Context', link: '/reference/frog-frame-context' }, { - text: 'Frog.frame Response', - link: '/reference/frog-frame-response', + text: 'Frog.frame', + link: '/reference/frog-frame', + items: [ + { text: 'Context', link: '/reference/frog-frame-context' }, + { + text: 'Response', + link: '/reference/frog-frame-response', + }, + ], + }, + { + text: 'Frog.transaction', + link: '/reference/frog-transaction', + items: [ + { text: 'Context', link: '/reference/frog-transaction-context' }, + { + text: 'Response', + link: '/reference/frog-transaction-response', + }, + ], }, { text: 'Frog.hono', link: '/reference/frog-hono' }, ], diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a8280147..00432a7b 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,5 +1,13 @@ import type { HtmlEscapedString } from 'hono/utils/html' +export const buttonPrefix = { + link: '_l', + mint: '_m', + redirect: '_r', + reset: '_c', + transaction: '_t', +} + export type ButtonProps = { children: string | string[] } @@ -45,7 +53,7 @@ export function ButtonLink({ , , , @@ -67,7 +75,7 @@ export function ButtonMint({ , , , @@ -90,7 +98,7 @@ export function ButtonRedirect({ property={`fc:frame:button:${index}`} content={normalizeChildren(children)} data-type="redirect" - data-value={`_r:${location}`} + data-value={`${buttonPrefix.redirect}:${location}`} />, ) } +export type ButtonTransactionProps = ButtonProps & { + target: string +} + +ButtonTransaction.__type = 'button' +export function ButtonTransaction({ + children, + // @ts-ignore - private + index = 1, + target, +}: ButtonTransactionProps) { + return [ + , + , + , + ] as unknown as HtmlEscapedString +} + export const Button = Object.assign(ButtonRoot, { Link: ButtonLink, Mint: ButtonMint, Redirect: ButtonRedirect, Reset: ButtonReset, + Transaction: ButtonTransaction, }) function normalizeChildren(children: string | string[]) { diff --git a/src/dev/components/Preview.tsx b/src/dev/components/Preview.tsx index ce783b0f..80ba033d 100644 --- a/src/dev/components/Preview.tsx +++ b/src/dev/components/Preview.tsx @@ -1,4 +1,4 @@ -import { type FrameContext } from '../../types/frame.js' +import { type FrameContext } from '../../types/context.js' import { type Frame as FrameType, type RequestBody } from '../types.js' import { Data } from './Data.js' import { Frame } from './Frame.js' diff --git a/src/dev/types.ts b/src/dev/types.ts index 093522a3..8e2a1e3b 100644 --- a/src/dev/types.ts +++ b/src/dev/types.ts @@ -1,5 +1,5 @@ +import type { FrameContext } from '../types/context.js' import { - type FrameContext, type FrameImageAspectRatio, type FrameVersion, } from '../types/frame.js' diff --git a/src/dev/utils/htmlToState.ts b/src/dev/utils/htmlToState.ts index 9900e2c8..8d3742f7 100644 --- a/src/dev/utils/htmlToState.ts +++ b/src/dev/utils/htmlToState.ts @@ -1,4 +1,4 @@ -import { type FrameContext } from '../../types/frame.js' +import { type FrameContext } from '../../types/context.js' import { deserializeJson } from '../../utils/deserializeJson.js' import { type FrogMetaTagPropertyName } from '../types.js' import { htmlToMetaTags } from './htmlToMetaTags.js' diff --git a/src/edge/index.ts b/src/edge/index.ts index 4bdc8dd5..57461016 100644 --- a/src/edge/index.ts +++ b/src/edge/index.ts @@ -7,10 +7,12 @@ export { } from '../components/Button.js' export { TextInput, type TextInputProps } from '../components/TextInput.js' -export type { - FrameOptions, - FrogConstructorParameters, -} from '../frog-base.js' +export type { FrogConstructorParameters, RouteOptions } from '../frog-base.js' export { FrogBase as Frog } from '../frog-base.js' -export type { FrameContext, FrameResponse } from '../types/frame.js' +export type { + Context, + FrameContext, + TransactionContext, +} from '../types/context.js' +export type { FrameResponse } from '../types/frame.js' diff --git a/src/frog-base.tsx b/src/frog-base.tsx index ccd7e390..23c16238 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -2,14 +2,15 @@ import { detect } from 'detect-browser' import { Hono } from 'hono' import { ImageResponse, type ImageResponseOptions } from 'hono-og' import { type HonoOptions } from 'hono/hono-base' +import { html } from 'hono/html' import { type Env, type Schema } from 'hono/types' // TODO: maybe write our own "modern" universal path (or resolve) module. // We are not using `node:path` to remain compatible with Edge runtimes. import { default as p } from 'path-browserify' -import { html } from 'hono/html' +import { transaction } from './routes/transaction.js' +import type { FrameContext } from './types/context.js' import { - type FrameContext, type FrameImageAspectRatio, type FrameResponse, } from './types/frame.js' @@ -145,7 +146,7 @@ export type FrogConstructorParameters< verify?: boolean | 'silent' | undefined } -export type FrameOptions = Pick +export type RouteOptions = Pick /** * A Frog instance. @@ -217,6 +218,8 @@ export class FrogBase< /** Whether or not frames should be verified. */ verify: FrogConstructorParameters['verify'] = true + transaction = transaction + constructor({ assetsPath, basePath, @@ -259,7 +262,7 @@ export class FrogBase< handler: ( context: Pretty>, ) => FrameResponse | Promise, - options: FrameOptions = {}, + options: RouteOptions = {}, ) { const { verify = this.verify } = options @@ -269,7 +272,7 @@ export class FrogBase< const assetsUrl = url.origin + parsePath(this.assetsPath) const baseUrl = url.origin + parsePath(this.basePath) - const context = await getFrameContext({ + const context = getFrameContext({ context: await requestToContext(c.req, { hub: this.hub || @@ -456,7 +459,7 @@ export class FrogBase< const queryContext = fromQuery< FrameContext & { state: state } >(query) - const context = await getFrameContext({ + const context = getFrameContext({ context: queryContext, cycle: 'image', initialState: this._initialState, diff --git a/src/frog.tsx b/src/frog.tsx index 7cefb0a4..481a898f 100644 --- a/src/frog.tsx +++ b/src/frog.tsx @@ -1,8 +1,9 @@ import { type Env, type Schema } from 'hono' import { routes as devRoutes } from './dev/routes.js' -import { type FrameOptions, FrogBase } from './frog-base.js' -import { type FrameContext, type FrameResponse } from './types/frame.js' +import { FrogBase, type RouteOptions } from './frog-base.js' +import { type FrameContext } from './types/context.js' +import { type FrameResponse } from './types/frame.js' import { type Pretty } from './types/utils.js' /** @@ -46,7 +47,7 @@ export class Frog< handler: ( context: Pretty>, ) => FrameResponse | Promise, - options: FrameOptions = {}, + options: RouteOptions = {}, ) { super.frame(path, handler, options) diff --git a/src/index.ts b/src/index.ts index a115bbb4..43147f9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export { parseEther } from 'viem' + export { Button, type ButtonLinkProps, @@ -8,8 +10,8 @@ export { export { TextInput, type TextInputProps } from './components/TextInput.js' export type { - FrameOptions, FrogConstructorParameters, + RouteOptions, } from './frog-base.js' export { Frog } from './frog.js' @@ -18,4 +20,15 @@ export { getFrameMetadata, } from './utils/getFrameMetadata.js' -export type { FrameContext, FrameResponse } from './types/frame.js' +export type { + Context, + FrameContext, + TransactionContext, +} from './types/context.js' +export type { FrameResponse } from './types/frame.js' +export type { + TransactionResponse, + ContractTransactionParameters, + SendTransactionParameters, + TransactionParameters, +} from './types/transaction.js' diff --git a/src/routes/transaction.ts b/src/routes/transaction.ts new file mode 100644 index 00000000..80d3455b --- /dev/null +++ b/src/routes/transaction.ts @@ -0,0 +1,33 @@ +import type { Env } from 'hono' + +import type { FrogBase, RouteOptions } from '../frog-base.js' +import type { TransactionContext } from '../types/context.js' +import type { TransactionResponse } from '../types/transaction.js' +import { getTransactionContext } from '../utils/getTransactionContext.js' +import { parsePath } from '../utils/parsePath.js' +import { requestToContext } from '../utils/requestToContext.js' + +export function transaction( + this: FrogBase, + path: string, + handler: ( + context: TransactionContext, + ) => TransactionResponse | Promise, + options: RouteOptions = {}, +) { + const { verify = this.verify } = options + + this.hono.post(parsePath(path), async (c) => { + const transactionContext = getTransactionContext({ + context: await requestToContext(c.req, { + hub: + this.hub || (this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined), + secret: this.secret, + verify, + }), + req: c.req, + }) + const transaction = await handler(transactionContext) + return c.json(transaction) + }) +} diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 00000000..7d6cf0f3 --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,111 @@ +import type { Context as Context_hono, Env } from 'hono' +import type { FrameButtonValue, FrameData, FrameResponseFn } from './frame.js' +import type { + ContractTransactionResponseFn, + SendTransactionParameters, + TransactionParameters, + TransactionResponseFn, +} from './transaction.js' +import type { Pretty } from './utils.js' + +export type Context = { + /** + * Index of the button that was interacted with on the previous frame. + */ + buttonIndex?: FrameData['buttonIndex'] + /** + * Value of the button that was interacted with on the previous frame. + */ + buttonValue?: string | undefined + /** + * Data from the frame that was passed via the POST body. + * The {@link Context`verified`} flag indicates whether the data is trusted or not. + */ + frameData?: Pretty + /** + * Initial path of the frame set. + */ + initialPath: string + /** + * Input text from the previous frame. + */ + inputText?: string | undefined + /** + * Button values from the previous frame. + */ + previousButtonValues?: FrameButtonValue[] | undefined + /** + * State from the previous frame. + */ + previousState: state + /** + * Status of the frame in the frame lifecycle. + * - `initial` - The frame has not yet been interacted with. + * - `redirect` - The frame interaction is a redirect (button of type `'post_redirect'`). + * - `response` - The frame has been interacted with (user presses button). + */ + status: 'initial' | 'redirect' | 'response' + /** + * Whether or not the {@link Context`frameData`} was verified by the Farcaster Hub API. + */ + verified: boolean + /** + * Current URL. + */ + url: Context_hono['req']['url'] +} + +export type FrameContext< + path extends string = string, + state = unknown, +> = Context & { + /** + * Current render cycle of the frame. + * + * - `main` - Render cycle for the main frame route. + * - `image` - Render cycle for the OG image route. + */ + cycle: 'main' | 'image' + /** + * Function to derive the frame's state based off the state from the + * previous frame. + */ + deriveState: (fn?: (previousState: state) => void) => state + getState: () => state + /** Frame request object. */ + req: Context_hono['req'] + /** Frame response that includes frame properties such as: image, intents, action, etc */ + res: FrameResponseFn + /** + * Transaction ID of the executed transaction (if any). Maps to: + * - Ethereum: a transaction hash + */ + transactionId?: FrameData['transactionId'] | undefined +} + +export type TransactionContext< + path extends string = string, + state = unknown, +> = Context & { + /** + * Contract transaction request. + * + * This is a convenience method for "eth_sendTransaction" requests for contracts as defined in the [Transaction Spec](https://www.notion.so/warpcast/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4#1b69c268f0684c978fbdf4d331ab8869), + * with a type-safe interface to infer types based on a provided `abi`. + */ + contract: ContractTransactionResponseFn + /** Frame request object. */ + req: Context_hono['req'] + /** + * Raw transaction request. + * + * @see https://www.notion.so/warpcast/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4#1b69c268f0684c978fbdf4d331ab8869 + */ + res: TransactionResponseFn + /** + * Send transaction request. + * + * This is a convenience method for "eth_sendTransaction" requests as defined in the [Transaction Spec](https://www.notion.so/warpcast/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4#1b69c268f0684c978fbdf4d331ab8869). + */ + send: TransactionResponseFn +} diff --git a/src/types/frame.ts b/src/types/frame.ts index 080cd141..da309444 100644 --- a/src/types/frame.ts +++ b/src/types/frame.ts @@ -1,70 +1,4 @@ -import { type Context, type Env } from 'hono' import { type ImageResponseOptions } from 'hono-og' -import type { Pretty } from './utils.js' - -export type FrameContext = { - /** - * Index of the button that was interacted with on the previous frame. - */ - buttonIndex?: FrameData['buttonIndex'] - /** - * Value of the button that was interacted with on the previous frame. - */ - buttonValue?: string | undefined - /** - * Current render cycle of the frame. - * - * - `main` - Render cycle for the main frame route. - * - `image` - Render cycle for the OG image route. - */ - cycle: 'main' | 'image' - /** - * Function to derive the frame's state based off the state from the - * previous frame. - */ - deriveState: (fn?: (previousState: state) => void) => state - /** - * Data from the frame that was passed via the POST body. - * The {@link FrameContext`verified`} flag indicates whether the data is trusted or not. - */ - frameData?: Pretty - getState: () => state - /** - * Initial path of the frame set. - */ - initialPath: string - /** - * Input text from the previous frame. - */ - inputText?: string | undefined - /** - * Button values from the previous frame. - */ - previousButtonValues?: FrameButtonValue[] | undefined - /** - * State from the previous frame. - */ - previousState: state - /** Frame request object. */ - req: Context['req'] - /** Frame response that includes frame properties such as: image, intents, action, etc */ - res: FrameResponseFn - /** - * Status of the frame in the frame lifecycle. - * - `initial` - The frame has not yet been interacted with. - * - `redirect` - The frame interaction is a redirect (button of type `'post_redirect'`). - * - `response` - The frame has been interacted with (user presses button). - */ - status: 'initial' | 'redirect' | 'response' - /** - * Whether or not the {@link FrameContext`frameData`} was verified by the Farcaster Hub API. - */ - verified: boolean - /** - * URL of the frame. - */ - url: Context['req']['url'] -} export type FrameResponse = { /** @@ -182,6 +116,7 @@ export type FrameData = { network: number state?: string | undefined timestamp: number + transactionId?: string | undefined url: string } diff --git a/src/types/transaction.ts b/src/types/transaction.ts new file mode 100644 index 00000000..b4f8a233 --- /dev/null +++ b/src/types/transaction.ts @@ -0,0 +1,88 @@ +import type { + Abi, + ContractFunctionArgs, + ContractFunctionName, + GetValue, + Hex, +} from 'viem' +import type { UnionWiden, Widen } from './utils.js' + +////////////////////////////////////////////////////// +// Raw Transaction + +export type TransactionParameters = { + /** A CAIP-2 Chain ID to identify the transaction network. */ + chainId: `eip155:${number}` +} & EthSendTransactionSchema + +export type TransactionResponse = Pick & + EthSendTransactionSchema + +export type EthSendTransactionSchema = { + /** A method ID to identify the type of transaction request. */ + method: 'eth_sendTransaction' + /** Transaction calldata. */ + params: EthSendTransactionParameters +} + +export type EthSendTransactionParameters = { + /** Contract ABI. */ + abi?: Abi | undefined + /** Transaction calldata. */ + data?: Hex | undefined + /** Transaction target address. */ + to: Hex + /** Value to send with transaction (in wei). */ + value?: quantity +} + +export type TransactionResponseFn = ( + parameters: parameters, +) => TransactionResponse + +////////////////////////////////////////////////////// +// Send Transaction + +export type SendTransactionParameters = Pick & + EthSendTransactionParameters + +////////////////////////////////////////////////////// +// Contract Transaction + +export type ContractTransactionParameters< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName< + abi, + 'nonpayable' | 'payable' + > = ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + > = ContractFunctionArgs, + /// + allFunctionNames = ContractFunctionName, + allArgs = ContractFunctionArgs, +> = Pick & { + /** Contract ABI. */ + abi: abi + /** Contract function arguments. */ + args?: (abi extends Abi ? UnionWiden : never) | allArgs | undefined + /** Contract function name to invoke. */ + functionName: + | allFunctionNames // show all options + | (functionName extends allFunctionNames ? functionName : never) // infer value +} & (readonly [] extends allArgs ? {} : { args: Widen }) & + GetValue + +export type ContractTransactionResponseFn = < + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, +>( + response: ContractTransactionParameters, +) => TransactionResponse diff --git a/src/types/utils.ts b/src/types/utils.ts index cf2474dd..13e26ad6 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1 +1,30 @@ +import type { ResolvedRegister } from 'viem' + export type Pretty = { [key in keyof type]: type[key] } & unknown + +export type Widen = + | ([unknown] extends [type] ? unknown : never) + | (type extends Function ? type : never) + | (type extends ResolvedRegister['BigIntType'] ? bigint : never) + | (type extends boolean ? boolean : never) + | (type extends ResolvedRegister['IntType'] ? number : never) + | (type extends string + ? type extends ResolvedRegister['AddressType'] + ? ResolvedRegister['AddressType'] + : type extends ResolvedRegister['BytesType']['inputs'] + ? ResolvedRegister['BytesType'] + : string + : never) + | (type extends readonly [] ? readonly [] : never) + | (type extends Record + ? { [K in keyof type]: Widen } + : never) + | (type extends { length: number } + ? { + [K in keyof type]: Widen + } extends infer Val extends readonly unknown[] + ? readonly [...Val] + : never + : never) + +export type UnionWiden = type extends any ? Widen : never diff --git a/src/utils/getFrameContext.ts b/src/utils/getFrameContext.ts index a8ab2bcf..199580e5 100644 --- a/src/utils/getFrameContext.ts +++ b/src/utils/getFrameContext.ts @@ -1,30 +1,21 @@ -import { type Context } from 'hono' +import { type HonoRequest } from 'hono' import { produce } from 'immer' -import { type FrameContext } from '../types/frame.js' +import type { Context, FrameContext } from '../types/context.js' import { getIntentState } from './getIntentState.js' import { parsePath } from './parsePath.js' type GetFrameContextParameters = { - context: Pick< - FrameContext, - | 'initialPath' - | 'previousState' - | 'previousButtonValues' - | 'frameData' - | 'status' - | 'url' - | 'verified' - > + context: Context cycle: FrameContext['cycle'] initialState?: state - req: Context['req'] + req: HonoRequest state?: state } -export async function getFrameContext( - options: GetFrameContextParameters, -): Promise> { - const { context, cycle, req, state } = options +export function getFrameContext( + parameters: GetFrameContextParameters, +): FrameContext { + const { context, cycle, req, state } = parameters const { frameData, initialPath, previousButtonValues, verified } = context || {} @@ -46,8 +37,8 @@ export async function getFrameContext( parsePath(context.url) let previousState = (() => { - if (context.status === 'initial') return options.initialState - return context?.previousState || options.initialState + if (context.status === 'initial') return parameters.initialState + return context?.previousState || parameters.initialState })() function deriveState(derive?: (state: state) => void): state { @@ -72,6 +63,7 @@ export async function getFrameContext( req, res: (data) => data, status, + transactionId: frameData?.transactionId, url, verified, } diff --git a/src/utils/getIntentState.ts b/src/utils/getIntentState.ts index fff2975d..6a3e78b4 100644 --- a/src/utils/getIntentState.ts +++ b/src/utils/getIntentState.ts @@ -1,3 +1,4 @@ +import { buttonPrefix } from '../components/Button.js' import { type FrameButtonValue, type FrameData } from '../types/frame.js' type IntentState = { @@ -28,8 +29,8 @@ export function getIntentState({ const intent = buttonIntents[buttonIndex - 1] if (!intent) return state - if (intent.startsWith('_c')) state.reset = true - else if (intent.startsWith('_r')) { + if (intent.startsWith(buttonPrefix.reset)) state.reset = true + else if (intent.startsWith(buttonPrefix.redirect)) { state.redirect = true state.buttonValue = intent.slice(3) } else state.buttonValue = intent diff --git a/src/utils/getTransactionContext.ts b/src/utils/getTransactionContext.ts new file mode 100644 index 00000000..c4e1daa5 --- /dev/null +++ b/src/utils/getTransactionContext.ts @@ -0,0 +1,97 @@ +import { type HonoRequest } from 'hono' +import { + type Abi, + AbiFunctionNotFoundError, + type EncodeFunctionDataParameters, + type GetAbiItemParameters, + encodeFunctionData, + getAbiItem, +} from 'viem' +import type { Context, TransactionContext } from '../types/context.js' +import type { TransactionResponse } from '../types/transaction.js' +import { getIntentState } from './getIntentState.js' + +type GetTransactionContextParameters = { + context: Context + req: HonoRequest +} + +export function getTransactionContext( + parameters: GetTransactionContextParameters, +): TransactionContext { + const { context, req } = parameters + const { + frameData, + initialPath, + previousButtonValues, + previousState, + status, + verified, + url, + } = context || {} + + const { buttonValue, inputText } = getIntentState({ + buttonValues: previousButtonValues || [], + frameData, + }) + + return { + buttonIndex: frameData?.buttonIndex, + buttonValue, + contract(parameters) { + const { abi, chainId, functionName, to, args, value } = parameters + + const abiItem = getAbiItem({ + abi: abi, + name: functionName, + args, + } as GetAbiItemParameters) + if (!abiItem) throw new AbiFunctionNotFoundError(functionName) + + const abiErrorItems = (abi as Abi).filter((item) => item.type === 'error') + + return this.send({ + abi: [abiItem, ...abiErrorItems], + chainId, + data: encodeFunctionData({ + abi, + args, + functionName, + } as EncodeFunctionDataParameters), + to, + value, + }) + }, + frameData, + initialPath, + inputText, + previousButtonValues, + previousState, + req, + res(parameters) { + const { chainId, method, params } = parameters + const { abi, data, to, value } = params + const response: TransactionResponse = { + chainId, + method, + params: { + abi, + data, + to, + }, + } + if (value) response.params.value = value.toString() + return response + }, + send(parameters) { + return this.res({ + chainId: parameters.chainId, + method: 'eth_sendTransaction', + params: parameters, + }) + }, + status, + verified, + url, + } +} diff --git a/src/utils/parseIntents.ts b/src/utils/parseIntents.ts index f355ad2d..a7d0a04e 100644 --- a/src/utils/parseIntents.ts +++ b/src/utils/parseIntents.ts @@ -1,5 +1,6 @@ import { type JSXNode } from 'hono/jsx' +import { buttonPrefix } from '../components/Button.js' import { type FrameIntents } from '../types/frame.js' import { parsePath } from './parsePath.js' @@ -44,8 +45,8 @@ function parseIntent( ) as JSXNode const props = (() => { - if ((node.tag as any).__type === 'button') - return { + if ((node.tag as any).__type === 'button') { + const buttonProps: Record = { ...node.props, action: node.props.action ? parsePath(options.baseUrl + node.props.action) + @@ -54,6 +55,18 @@ function parseIntent( children: node.children, index: counter.button++, } + + const value = (node.tag as any)({})?.[0]?.props?.['data-value'] + if (value?.startsWith(buttonPrefix.transaction) && node.props.target) { + const search = (node.props.target ?? '').split('?')[1] + buttonProps.target = node.props.target?.startsWith('http') + ? node.props.target + : parsePath(options.baseUrl + node.props.target) + + (search ? `?${search}` : '') + } + + return buttonProps + } if ((node.tag as any).__type === 'text-input') return { ...node.props, children: node.children } return {} diff --git a/src/utils/requestToContext.ts b/src/utils/requestToContext.ts index 79b41729..1290a014 100644 --- a/src/utils/requestToContext.ts +++ b/src/utils/requestToContext.ts @@ -1,6 +1,6 @@ -import { type Context } from 'hono' +import { type HonoRequest } from 'hono' import type { FrogConstructorParameters } from '../frog-base.js' -import { type FrameContext } from '../types/frame.js' +import type { Context } from '../types/context.js' import type { Hub } from '../types/hub.js' import { deserializeJson } from './deserializeJson.js' import { fromQuery } from './fromQuery.js' @@ -13,19 +13,10 @@ type RequestToContextOptions = { verify?: FrogConstructorParameters['verify'] } -type RequestToContextReturnType = Pick< - FrameContext, - | 'initialPath' - | 'previousState' - | 'previousButtonValues' - | 'frameData' - | 'status' - | 'url' - | 'verified' -> +type RequestToContextReturnType = Context export async function requestToContext( - req: Context['req'], + req: HonoRequest, { hub, secret, verify = true }: RequestToContextOptions, ): Promise> { const { trustedData, untrustedData } = From af6828a87e4f08e5b9ff76d5a7337ba18e42d773 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 07:26:17 +1100 Subject: [PATCH 07/20] chore: changeset --- .changeset/hot-otters-retire.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-otters-retire.md diff --git a/.changeset/hot-otters-retire.md b/.changeset/hot-otters-retire.md new file mode 100644 index 00000000..710b092d --- /dev/null +++ b/.changeset/hot-otters-retire.md @@ -0,0 +1,5 @@ +--- +"frog": minor +--- + +Added Transaction support. [Read more.](https://frog.fm/concepts/transactions) From b492dab3fe1f7038ffae454ccefff4654fe48c99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 07:31:11 +1100 Subject: [PATCH 08/20] chore: version packages (#76) Co-authored-by: github-actions[bot] --- .changeset/hot-otters-retire.md | 5 ----- src/CHANGELOG.md | 6 ++++++ src/package.json | 2 +- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/hot-otters-retire.md diff --git a/.changeset/hot-otters-retire.md b/.changeset/hot-otters-retire.md deleted file mode 100644 index 710b092d..00000000 --- a/.changeset/hot-otters-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": minor ---- - -Added Transaction support. [Read more.](https://frog.fm/concepts/transactions) diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index ed3b783e..714415f0 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,11 @@ # frog +## 0.3.0 + +### Minor Changes + +- [`af6828a`](https://github.com/wevm/frog/commit/af6828a87e4f08e5b9ff76d5a7337ba18e42d773) Thanks [@jxom](https://github.com/jxom)! - Added Transaction support. [Read more.](https://frog.fm/concepts/transactions) + ## 0.2.14 ### Patch Changes diff --git a/src/package.json b/src/package.json index 1aa4690b..458f03d2 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.2.14", + "version": "0.3.0", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", diff --git a/src/version.ts b/src/version.ts index 71619264..aa9835f5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.2.14' +export const version = '0.3.0' From 149d5221ae61cbef4405766f314a092dee010f70 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 08:02:24 +1100 Subject: [PATCH 09/20] docs: typos --- site/pages/reference/frog-transaction-context.mdx | 2 +- site/pages/reference/frog-transaction-response.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/pages/reference/frog-transaction-context.mdx b/site/pages/reference/frog-transaction-context.mdx index 8953be90..53c2ebed 100644 --- a/site/pages/reference/frog-transaction-context.mdx +++ b/site/pages/reference/frog-transaction-context.mdx @@ -320,7 +320,7 @@ app.transaction('/send-ether', (c) => { - **Type**: `TransactionResponseFn` -Raw transaction response. [See more.](/reference/frog-transaction-response#raw-transaction-craw) +Raw transaction response. [See more.](/reference/frog-transaction-response#raw-transaction-cres) ```tsx twoslash // @noErrors diff --git a/site/pages/reference/frog-transaction-response.mdx b/site/pages/reference/frog-transaction-response.mdx index 078bb08b..25b0a412 100644 --- a/site/pages/reference/frog-transaction-response.mdx +++ b/site/pages/reference/frog-transaction-response.mdx @@ -6,7 +6,7 @@ There are three types of responses: - [Send Transaction (`c.send`)](#send-transaction-csend): Convinience method to **send a transaction**. - [Contract Transaction (`c.contract`)](#contract-transaction-ccontract): Convinience method to **invoke a contract function** (with inferred types & automatic encoding). -- [Raw Transaction (`c.raw`)](#raw-transaction-craw): Low-level method to **send raw transaction** (mimics the [Transaction Spec API](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4)). +- [Raw Transaction (`c.res`)](#raw-transaction-cres): Low-level method to **send raw transaction** (mimics the [Transaction Spec API](https://warpcast.notion.site/Frame-Transactions-Public-Draft-v2-9d9f9f4f527249519a41bd8d16165f73?pvs=4)). ```tsx twoslash // @noErrors @@ -288,7 +288,7 @@ app.transaction('/mint', (c) => { }) ``` -## Raw Transaction (`c.raw`) +## Raw Transaction (`c.res`) ### chainId From 00725e7be52727d2203e86d5855f824f6e1a96e9 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 10:51:28 +1100 Subject: [PATCH 10/20] feat: widen response types --- .changeset/chatty-ears-wash.md | 5 +++++ playground/src/transaction.tsx | 6 +++--- .../pages/reference/frog-transaction-response.mdx | 15 +++++++++++++++ src/frog-base.tsx | 13 ++++++++++--- src/frog.tsx | 3 ++- src/routes/transaction.ts | 8 +++++--- src/types/frame.ts | 5 ++++- src/types/response.ts | 10 ++++++++++ src/types/transaction.ts | 5 +++-- src/utils/getFrameContext.ts | 2 +- src/utils/getTransactionContext.ts | 2 +- 11 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 .changeset/chatty-ears-wash.md create mode 100644 src/types/response.ts diff --git a/.changeset/chatty-ears-wash.md b/.changeset/chatty-ears-wash.md new file mode 100644 index 00000000..eb132714 --- /dev/null +++ b/.changeset/chatty-ears-wash.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Widened handler return types to allow [`Response` objects](https://developer.mozilla.org/en-US/docs/Web/API/Response). diff --git a/playground/src/transaction.tsx b/playground/src/transaction.tsx index 97f515bd..f7518760 100644 --- a/playground/src/transaction.tsx +++ b/playground/src/transaction.tsx @@ -2,8 +2,8 @@ import { Button, Frog } from 'frog' export const app = new Frog() -app.frame('/', () => { - return { +app.frame('/', (c) => { + return c.res({ image: (
Example @@ -14,7 +14,7 @@ app.frame('/', () => { Send Transaction, Mint, ], - } + }) }) // Raw transaction diff --git a/site/pages/reference/frog-transaction-response.mdx b/site/pages/reference/frog-transaction-response.mdx index 25b0a412..85267fa9 100644 --- a/site/pages/reference/frog-transaction-response.mdx +++ b/site/pages/reference/frog-transaction-response.mdx @@ -43,6 +43,21 @@ app.transaction('/raw-send', (c) => { }) ``` +:::tip +Frog also supports [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects as a return value of the `.transaction` handler. Useful for returning error responses. + +```tsx twoslash +// @noErrors +import { Frog, parseEther } from 'frog' + +export const app = new Frog() + +app.transaction('/send-ether', (c) => { + return new Response('dang', { status: 400 }) // [!code focus] +}) +``` +::: + ## Send Transaction (`c.send`) ### chainId diff --git a/src/frog-base.tsx b/src/frog-base.tsx index 23c16238..a238b9ba 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -15,6 +15,7 @@ import { type FrameResponse, } from './types/frame.js' import type { Hub } from './types/hub.js' +import type { HandlerResponse } from './types/response.js' import { type Pretty } from './types/utils.js' import { fromQuery } from './utils/fromQuery.js' import { getButtonValues } from './utils/getButtonValues.js' @@ -261,7 +262,7 @@ export class FrogBase< path: path, handler: ( context: Pretty>, - ) => FrameResponse | Promise, + ) => HandlerResponse, options: RouteOptions = {}, ) { const { verify = this.verify } = options @@ -287,6 +288,9 @@ export class FrogBase< if (context.url !== parsePath(c.req.url)) return c.redirect(context.url) + const response = await handler(context) + if (response instanceof Response) return response + const { action, browserLocation = this.browserLocation, @@ -295,7 +299,7 @@ export class FrogBase< image, intents, title = 'Frog Frame', - } = await handler(context) + } = response.data const buttonValues = getButtonValues(parseIntents(intents)) if (context.status === 'redirect' && context.buttonIndex) { @@ -472,11 +476,14 @@ export class FrogBase< ? await this.imageOptions() : this.imageOptions + const response = await handler(context) + if (response instanceof Response) return response + const { image, headers = this.headers, imageOptions = defaultImageOptions, - } = await handler(context) + } = response.data if (typeof image === 'string') return c.redirect(image, 302) return new ImageResponse(image, { ...imageOptions, diff --git a/src/frog.tsx b/src/frog.tsx index 481a898f..d8a7dc28 100644 --- a/src/frog.tsx +++ b/src/frog.tsx @@ -4,6 +4,7 @@ import { routes as devRoutes } from './dev/routes.js' import { FrogBase, type RouteOptions } from './frog-base.js' import { type FrameContext } from './types/context.js' import { type FrameResponse } from './types/frame.js' +import type { HandlerResponse } from './types/response.js' import { type Pretty } from './types/utils.js' /** @@ -46,7 +47,7 @@ export class Frog< path: path, handler: ( context: Pretty>, - ) => FrameResponse | Promise, + ) => HandlerResponse, options: RouteOptions = {}, ) { super.frame(path, handler, options) diff --git a/src/routes/transaction.ts b/src/routes/transaction.ts index 80d3455b..dac76c40 100644 --- a/src/routes/transaction.ts +++ b/src/routes/transaction.ts @@ -2,6 +2,7 @@ import type { Env } from 'hono' import type { FrogBase, RouteOptions } from '../frog-base.js' import type { TransactionContext } from '../types/context.js' +import type { HandlerResponse } from '../types/response.js' import type { TransactionResponse } from '../types/transaction.js' import { getTransactionContext } from '../utils/getTransactionContext.js' import { parsePath } from '../utils/parsePath.js' @@ -12,7 +13,7 @@ export function transaction( path: string, handler: ( context: TransactionContext, - ) => TransactionResponse | Promise, + ) => HandlerResponse, options: RouteOptions = {}, ) { const { verify = this.verify } = options @@ -27,7 +28,8 @@ export function transaction( }), req: c.req, }) - const transaction = await handler(transactionContext) - return c.json(transaction) + const response = await handler(transactionContext) + if (response instanceof Response) return response + return c.json(response.data) }) } diff --git a/src/types/frame.ts b/src/types/frame.ts index da309444..3b470a73 100644 --- a/src/types/frame.ts +++ b/src/types/frame.ts @@ -1,4 +1,5 @@ import { type ImageResponseOptions } from 'hono-og' +import type { TypedResponse } from './response.js' export type FrameResponse = { /** @@ -105,7 +106,9 @@ export type FrameResponse = { title?: string | undefined } -export type FrameResponseFn = (response: FrameResponse) => FrameResponse +export type FrameResponseFn = ( + response: FrameResponse, +) => TypedResponse export type FrameData = { buttonIndex?: 1 | 2 | 3 | 4 | undefined diff --git a/src/types/response.ts b/src/types/response.ts new file mode 100644 index 00000000..693fc9af --- /dev/null +++ b/src/types/response.ts @@ -0,0 +1,10 @@ +export type TypedResponse = { + data: data + format: 'frame' | 'transaction' +} + +export type HandlerResponse = + | Response + | TypedResponse + | Promise + | Promise> diff --git a/src/types/transaction.ts b/src/types/transaction.ts index b4f8a233..7ad8d3e4 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -5,6 +5,7 @@ import type { GetValue, Hex, } from 'viem' +import type { TypedResponse } from './response.js' import type { UnionWiden, Widen } from './utils.js' ////////////////////////////////////////////////////// @@ -38,7 +39,7 @@ export type EthSendTransactionParameters = { export type TransactionResponseFn = ( parameters: parameters, -) => TransactionResponse +) => TypedResponse ////////////////////////////////////////////////////// // Send Transaction @@ -85,4 +86,4 @@ export type ContractTransactionResponseFn = < >, >( response: ContractTransactionParameters, -) => TransactionResponse +) => TypedResponse diff --git a/src/utils/getFrameContext.ts b/src/utils/getFrameContext.ts index 199580e5..feaba2d3 100644 --- a/src/utils/getFrameContext.ts +++ b/src/utils/getFrameContext.ts @@ -61,7 +61,7 @@ export function getFrameContext( previousButtonValues, previousState: previousState as any, req, - res: (data) => data, + res: (data) => ({ data, format: 'frame' }), status, transactionId: frameData?.transactionId, url, diff --git a/src/utils/getTransactionContext.ts b/src/utils/getTransactionContext.ts index c4e1daa5..d58f0000 100644 --- a/src/utils/getTransactionContext.ts +++ b/src/utils/getTransactionContext.ts @@ -81,7 +81,7 @@ export function getTransactionContext( }, } if (value) response.params.value = value.toString() - return response + return { data: response, format: 'transaction' } }, send(parameters) { return this.res({ From 4e05fb5fd746dab8c8f4611ec13799cb71262fe3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:53:00 +1100 Subject: [PATCH 11/20] chore: version packages (#79) Co-authored-by: github-actions[bot] --- .changeset/chatty-ears-wash.md | 5 ----- src/CHANGELOG.md | 6 ++++++ src/package.json | 2 +- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/chatty-ears-wash.md diff --git a/.changeset/chatty-ears-wash.md b/.changeset/chatty-ears-wash.md deleted file mode 100644 index eb132714..00000000 --- a/.changeset/chatty-ears-wash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": patch ---- - -Widened handler return types to allow [`Response` objects](https://developer.mozilla.org/en-US/docs/Web/API/Response). diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 714415f0..357dfdca 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,11 @@ # frog +## 0.3.1 + +### Patch Changes + +- [`00725e7`](https://github.com/wevm/frog/commit/00725e7be52727d2203e86d5855f824f6e1a96e9) Thanks [@jxom](https://github.com/jxom)! - Widened handler return types to allow [`Response` objects](https://developer.mozilla.org/en-US/docs/Web/API/Response). + ## 0.3.0 ### Minor Changes diff --git a/src/package.json b/src/package.json index 458f03d2..a8c92413 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", diff --git a/src/version.ts b/src/version.ts index aa9835f5..9a71e443 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.3.0' +export const version = '0.3.1' From f800940eb89ffe41d46b724336765988a4a0b3df Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 11:15:42 +1100 Subject: [PATCH 12/20] feat: add pinata hub --- .changeset/cyan-goats-join.md | 5 +++++ site/pages/hubs/pinata.mdx | 25 +++++++++++++++++++++++++ site/vocs.config.tsx | 4 ++++ src/hubs/index.ts | 1 + src/hubs/pinata.ts | 7 +++++++ 5 files changed, 42 insertions(+) create mode 100644 .changeset/cyan-goats-join.md create mode 100644 site/pages/hubs/pinata.mdx create mode 100644 src/hubs/pinata.ts diff --git a/.changeset/cyan-goats-join.md b/.changeset/cyan-goats-join.md new file mode 100644 index 00000000..5b83e2b7 --- /dev/null +++ b/.changeset/cyan-goats-join.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Added `pinata` hub. diff --git a/site/pages/hubs/pinata.mdx b/site/pages/hubs/pinata.mdx new file mode 100644 index 00000000..9c6e44a4 --- /dev/null +++ b/site/pages/hubs/pinata.mdx @@ -0,0 +1,25 @@ +# Pinata + +Pinata provides a free to use Farcaster Hub that you can use inside of Frog. + +[Learn more about the Pinata Hub.](https://docs.pinata.cloud/farcaster/hubs) + +## Import + +```ts twoslash +import { pinata } from 'frog/hubs' +``` + +## Usage + +```tsx twoslash +// @noErrors +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Frog } from 'frog' +import { pinata } from 'frog/hubs' // [!code focus] + +export const app = new Frog({ + hub: pinata() // [!code focus] +}) +``` diff --git a/site/vocs.config.tsx b/site/vocs.config.tsx index ce501d70..ee19095e 100644 --- a/site/vocs.config.tsx +++ b/site/vocs.config.tsx @@ -340,6 +340,10 @@ export default defineConfig({ text: 'Neynar', link: '/hubs/neynar', }, + { + text: 'Pinata', + link: '/hubs/pinata', + }, ], }, { diff --git a/src/hubs/index.ts b/src/hubs/index.ts index 7c5d0ae7..5768ed02 100644 --- a/src/hubs/index.ts +++ b/src/hubs/index.ts @@ -1,2 +1,3 @@ export { frog } from './frog.js' export { neynar } from './neynar.js' +export { pinata } from './pinata.js' diff --git a/src/hubs/pinata.ts b/src/hubs/pinata.ts new file mode 100644 index 00000000..63a22b37 --- /dev/null +++ b/src/hubs/pinata.ts @@ -0,0 +1,7 @@ +import { createHub } from './utils.js' + +export const pinata = createHub(() => { + return { + apiUrl: 'https://hub.pinata.cloud', + } +}) From 4c0aab608d2a2213de1d0d3aabe15b5d7cfbaa34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 11:17:26 +1100 Subject: [PATCH 13/20] chore: version packages (#81) Co-authored-by: github-actions[bot] --- .changeset/cyan-goats-join.md | 5 ----- src/CHANGELOG.md | 6 ++++++ src/package.json | 2 +- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/cyan-goats-join.md diff --git a/.changeset/cyan-goats-join.md b/.changeset/cyan-goats-join.md deleted file mode 100644 index 5b83e2b7..00000000 --- a/.changeset/cyan-goats-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": patch ---- - -Added `pinata` hub. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 357dfdca..d8ba58b3 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,11 @@ # frog +## 0.3.2 + +### Patch Changes + +- [`f800940`](https://github.com/wevm/frog/commit/f800940eb89ffe41d46b724336765988a4a0b3df) Thanks [@jxom](https://github.com/jxom)! - Added `pinata` hub. + ## 0.3.1 ### Patch Changes diff --git a/src/package.json b/src/package.json index a8c92413..aa56ccd1 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.3.1", + "version": "0.3.2", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", diff --git a/src/version.ts b/src/version.ts index 9a71e443..c2b325a1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.3.1' +export const version = '0.3.2' From 142040e1a73ccd9d5f82c7b6578173c65c3dc3c6 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 11:34:42 +1100 Subject: [PATCH 14/20] fix: frame verification url --- .changeset/cold-ears-sit.md | 5 +++++ src/utils/verifyFrame.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/cold-ears-sit.md diff --git a/.changeset/cold-ears-sit.md b/.changeset/cold-ears-sit.md new file mode 100644 index 00000000..9dd776de --- /dev/null +++ b/.changeset/cold-ears-sit.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Fixed URL comparison for frame verification. diff --git a/src/utils/verifyFrame.ts b/src/utils/verifyFrame.ts index 40b70a73..e37c9b27 100644 --- a/src/utils/verifyFrame.ts +++ b/src/utils/verifyFrame.ts @@ -35,7 +35,7 @@ export async function verifyFrame({ if (!response.valid) throw new Error(`message is invalid. ${response.details}`) - if (!parsePath(frameUrl)?.startsWith(parsePath(url))) + if (!parsePath(url)?.startsWith(parsePath(frameUrl))) throw new Error(`Invalid frame url: ${frameUrl}. Expected: ${url}.`) const message = Message.fromBinary(body) From 963d071e2259ee92546419ac4d0219d96f31babf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 11:37:25 +1100 Subject: [PATCH 15/20] chore: version packages (#83) Co-authored-by: github-actions[bot] --- .changeset/cold-ears-sit.md | 5 ----- src/CHANGELOG.md | 6 ++++++ src/package.json | 2 +- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/cold-ears-sit.md diff --git a/.changeset/cold-ears-sit.md b/.changeset/cold-ears-sit.md deleted file mode 100644 index 9dd776de..00000000 --- a/.changeset/cold-ears-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": patch ---- - -Fixed URL comparison for frame verification. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index d8ba58b3..725179a7 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,11 @@ # frog +## 0.3.3 + +### Patch Changes + +- [`142040e`](https://github.com/wevm/frog/commit/142040e1a73ccd9d5f82c7b6578173c65c3dc3c6) Thanks [@jxom](https://github.com/jxom)! - Fixed URL comparison for frame verification. + ## 0.3.2 ### Patch Changes diff --git a/src/package.json b/src/package.json index aa56ccd1..3fb60223 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.3.2", + "version": "0.3.3", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", diff --git a/src/version.ts b/src/version.ts index c2b325a1..5c54b771 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.3.2' +export const version = '0.3.3' From e657a0cb07299f730e016818768df9218c747c94 Mon Sep 17 00:00:00 2001 From: "moxey.eth" Date: Wed, 6 Mar 2024 11:45:32 +1100 Subject: [PATCH 16/20] chore: changeset --- .changeset/flat-wolves-camp.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/flat-wolves-camp.md diff --git a/.changeset/flat-wolves-camp.md b/.changeset/flat-wolves-camp.md new file mode 100644 index 00000000..dfaac4ba --- /dev/null +++ b/.changeset/flat-wolves-camp.md @@ -0,0 +1,5 @@ +--- +"create-frog": patch +--- + +Updated templates. From eb2422b6a989e73bce1d0f926663d043943e732f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 11:49:00 +1100 Subject: [PATCH 17/20] chore: version packages (#84) Co-authored-by: github-actions[bot] --- .changeset/flat-wolves-camp.md | 5 ----- create-frog/CHANGELOG.md | 6 ++++++ create-frog/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/flat-wolves-camp.md diff --git a/.changeset/flat-wolves-camp.md b/.changeset/flat-wolves-camp.md deleted file mode 100644 index dfaac4ba..00000000 --- a/.changeset/flat-wolves-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"create-frog": patch ---- - -Updated templates. diff --git a/create-frog/CHANGELOG.md b/create-frog/CHANGELOG.md index 2ea90c45..1d49cc62 100644 --- a/create-frog/CHANGELOG.md +++ b/create-frog/CHANGELOG.md @@ -1,5 +1,11 @@ # create-frog +## 0.1.4 + +### Patch Changes + +- [`e657a0c`](https://github.com/wevm/frog/commit/e657a0cb07299f730e016818768df9218c747c94) Thanks [@jxom](https://github.com/jxom)! - Updated templates. + ## 0.1.3 ### Patch Changes diff --git a/create-frog/package.json b/create-frog/package.json index fdf21977..b33f00e2 100644 --- a/create-frog/package.json +++ b/create-frog/package.json @@ -1,6 +1,6 @@ { "name": "create-frog", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "bin": { "create-frog": "./_lib/bin.js" From c3775288bc8683d532d9c6ca2cd05e6f2f1bd69d Mon Sep 17 00:00:00 2001 From: jxom Date: Wed, 6 Mar 2024 14:44:27 +1100 Subject: [PATCH 18/20] tweaks: types + middleware (#80) * tweaks: refactor for middleware * docs: update * tweaks * chore: changesets --- .changeset/odd-hats-rush.md | 14 ++ .changeset/unlucky-lions-tan.md | 5 + playground/src/index.tsx | 2 + playground/src/middleware.tsx | 29 ++++ playground/src/todos.tsx | 6 +- services/frame/api/index.tsx | 2 +- site/pages/concepts/state-management.mdx | 2 +- site/pages/reference/frog-frame-context.mdx | 29 +++- .../reference/frog-transaction-context.mdx | 27 +++- site/pages/reference/frog.mdx | 2 +- src/dev/routes.tsx | 8 +- src/frog-base.tsx | 60 ++++---- src/frog.tsx | 12 +- src/routes/transaction.ts | 24 +-- src/types/context.ts | 54 +++++-- src/types/env.ts | 5 + src/utils/getFrameContext.ts | 82 ++++++---- src/utils/getTransactionContext.ts | 142 +++++++++++------- ...stToContext.ts => requestBodyToContext.ts} | 39 +++-- src/utils/requestQueryToContext.ts | 32 ++++ src/vercel/handle.ts | 2 +- tsconfig.json | 2 +- 22 files changed, 409 insertions(+), 171 deletions(-) create mode 100644 .changeset/odd-hats-rush.md create mode 100644 .changeset/unlucky-lions-tan.md create mode 100644 playground/src/middleware.tsx create mode 100644 src/types/env.ts rename src/utils/{requestToContext.ts => requestBodyToContext.ts} (63%) create mode 100644 src/utils/requestQueryToContext.ts diff --git a/.changeset/odd-hats-rush.md b/.changeset/odd-hats-rush.md new file mode 100644 index 00000000..5fb26da7 --- /dev/null +++ b/.changeset/odd-hats-rush.md @@ -0,0 +1,14 @@ +--- +"frog": patch +--- + +**Type Change:** The `state` generic in the `Frog` constructor type is now named. + +```diff +type State = { count: number } + +- const frog = new Frog({ ++ const frog = new Frog<{ State: State }>({ + initialState: { count: 0 } +}) +``` diff --git a/.changeset/unlucky-lions-tan.md b/.changeset/unlucky-lions-tan.md new file mode 100644 index 00000000..755a1a56 --- /dev/null +++ b/.changeset/unlucky-lions-tan.md @@ -0,0 +1,5 @@ +--- +"frog": patch +--- + +Added a `var` property to context to extract variables that were previously set via `set` in Middleware. [Read more.](https://frog.fm/reference/frog-frame-context#var) diff --git a/playground/src/index.tsx b/playground/src/index.tsx index 073ccaf2..a58f1885 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -1,6 +1,7 @@ import { Button, Frog, TextInput } from 'frog' import * as hubs from 'frog/hubs' +import { app as middlewareApp } from './middleware.js' import { app as routingApp } from './routing.js' import { app as todoApp } from './todos.js' import { app as transactionApp } from './transaction.js' @@ -226,6 +227,7 @@ app.frame('/redirect-buttons', (c) => { }) }) +app.route('/middleware', middlewareApp) app.route('/routing', routingApp) app.route('/transaction', transactionApp) app.route('/todos', todoApp) diff --git a/playground/src/middleware.tsx b/playground/src/middleware.tsx new file mode 100644 index 00000000..c21e5887 --- /dev/null +++ b/playground/src/middleware.tsx @@ -0,0 +1,29 @@ +import { Frog } from 'frog' +import type { MiddlewareHandler } from 'hono' + +type EchoMiddlewareVariables = { + echo: (str: string) => string +} + +const echoMiddleware: MiddlewareHandler<{ + Variables: EchoMiddlewareVariables +}> = async (c, next) => { + c.set('echo', (str) => str) + await next() +} + +export const app = new Frog<{ + Variables: EchoMiddlewareVariables +}>() + +app.use(echoMiddleware) + +app.frame('/', (c) => { + return c.res({ + image: ( +
+ {c.var.echo('hello world!')} +
+ ), + }) +}) diff --git a/playground/src/todos.tsx b/playground/src/todos.tsx index e0a896d3..65ff7f63 100644 --- a/playground/src/todos.tsx +++ b/playground/src/todos.tsx @@ -1,8 +1,10 @@ import { Button, Frog, TextInput } from 'frog' export const app = new Frog<{ - index: number - todos: { completed: boolean; name: string }[] + State: { + index: number + todos: { completed: boolean; name: string }[] + } }>({ initialState: { index: -1, diff --git a/services/frame/api/index.tsx b/services/frame/api/index.tsx index 5e4d0289..1e5baf55 100644 --- a/services/frame/api/index.tsx +++ b/services/frame/api/index.tsx @@ -10,7 +10,7 @@ export const config = { runtime: 'edge', } -export const app = new Frog({ +export const app = new Frog<{ State: State }>({ assetsPath: '/', basePath: '/api', browserLocation: 'https://frog.fm', diff --git a/site/pages/concepts/state-management.mdx b/site/pages/concepts/state-management.mdx index d916b467..71bf3e0b 100644 --- a/site/pages/concepts/state-management.mdx +++ b/site/pages/concepts/state-management.mdx @@ -19,7 +19,7 @@ type State = { // [!code focus] count: number // [!code focus] } // [!code focus] -export const app = new Frog({ // [!code focus] +export const app = new Frog<{ State: State }>({ // [!code focus] initialState: { // [!code focus] count: 0 // [!code focus] } // [!code focus] diff --git a/site/pages/reference/frog-frame-context.mdx b/site/pages/reference/frog-frame-context.mdx index b29dd5ac..fd9e1048 100644 --- a/site/pages/reference/frog-frame-context.mdx +++ b/site/pages/reference/frog-frame-context.mdx @@ -133,7 +133,7 @@ type State = { // [!code focus] values: string[] // [!code focus] } // [!code focus] -export const app = new Frog({ // [!code focus] +export const app = new Frog<{ State: State }>({ // [!code focus] initialState: { // [!code focus] values: [] // [!code focus] } // [!code focus] @@ -297,7 +297,7 @@ type State = { values: string[] } -export const app = new Frog({ +export const app = new Frog<{ State: State }>({ initialState: { values: [] } @@ -386,6 +386,31 @@ app.frame('/', (c) => { }) ``` +## 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.frame('/', (c) => { + const message = c.var.message // [!code focus] + return c.res({/* ... */}) +}) +``` + ## verified - **Type**: `boolean` diff --git a/site/pages/reference/frog-transaction-context.mdx b/site/pages/reference/frog-transaction-context.mdx index 53c2ebed..b59eb5d1 100644 --- a/site/pages/reference/frog-transaction-context.mdx +++ b/site/pages/reference/frog-transaction-context.mdx @@ -265,7 +265,7 @@ type State = { values: string[] } -export const app = new Frog({ +export const app = new Frog<{ State: State }>({ initialState: { values: [] } @@ -354,6 +354,31 @@ app.transaction('/send-ether', (c) => { }) ``` +## 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.transaction('/send-ether', (c) => { + const message = c.var.message // [!code focus] + return c.send({/* ... */}) +}) +``` + ## verified - **Type**: `boolean` diff --git a/site/pages/reference/frog.mdx b/site/pages/reference/frog.mdx index 8c1b2328..0f0f0921 100644 --- a/site/pages/reference/frog.mdx +++ b/site/pages/reference/frog.mdx @@ -153,7 +153,7 @@ type State = { todos: string[] } -const app = new Frog({ +const app = new Frog<{ State: State }>({ initialState: { // [!code focus] index: 0, // [!code focus] todos: [] // [!code focus] diff --git a/src/dev/routes.tsx b/src/dev/routes.tsx index 334d3f92..9207427a 100644 --- a/src/dev/routes.tsx +++ b/src/dev/routes.tsx @@ -1,6 +1,6 @@ import { bytesToHex } from '@noble/curves/abstract/utils' import { ed25519 } from '@noble/curves/ed25519' -import { type Env, type Schema } from 'hono' +import { type Schema } from 'hono' import { deleteCookie, getCookie, @@ -15,6 +15,7 @@ import { validator } from 'hono/validator' import { mnemonicToAccount } from 'viem/accounts' import { type FrogBase } from '../frog-base.js' +import type { Env } from '../types/env.js' import { verify } from '../utils/jws.js' import { parsePath } from '../utils/parsePath.js' import { toSearchParams } from '../utils/toSearchParams.js' @@ -37,11 +38,12 @@ import { uid } from './utils/uid.js' import { validateFramePostBody } from './utils/validateFramePostBody.js' export function routes< - state, env extends Env, schema extends Schema, basePath extends string, ->(app: FrogBase, path: string) { + // + _state = env['State'], +>(app: FrogBase, path: string) { app .use(`${parsePath(path)}/dev`, (c, next) => jsxRenderer((props) => { diff --git a/src/frog-base.tsx b/src/frog-base.tsx index a238b9ba..76b0a67e 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -3,13 +3,14 @@ import { Hono } from 'hono' import { ImageResponse, type ImageResponseOptions } from 'hono-og' import { type HonoOptions } from 'hono/hono-base' import { html } from 'hono/html' -import { type Env, type Schema } from 'hono/types' +import { type Schema } from 'hono/types' // TODO: maybe write our own "modern" universal path (or resolve) module. // We are not using `node:path` to remain compatible with Edge runtimes. import { default as p } from 'path-browserify' import { transaction } from './routes/transaction.js' -import type { FrameContext } from './types/context.js' +import type { FrameContext, FrameQueryContext } from './types/context.js' +import type { Env } from './types/env.js' import { type FrameImageAspectRatio, type FrameResponse, @@ -17,22 +18,23 @@ import { import type { Hub } from './types/hub.js' import type { HandlerResponse } from './types/response.js' import { type Pretty } from './types/utils.js' -import { fromQuery } from './utils/fromQuery.js' import { getButtonValues } from './utils/getButtonValues.js' import { getFrameContext } from './utils/getFrameContext.js' import * as jws from './utils/jws.js' import { parseBrowserLocation } from './utils/parseBrowserLocation.js' import { parseIntents } from './utils/parseIntents.js' import { parsePath } from './utils/parsePath.js' -import { requestToContext } from './utils/requestToContext.js' +import { requestBodyToContext } from './utils/requestBodyToContext.js' +import { requestQueryToContext } from './utils/requestQueryToContext.js' import { serializeJson } from './utils/serializeJson.js' import { toSearchParams } from './utils/toSearchParams.js' import { version } from './version.js' export type FrogConstructorParameters< - state = undefined, env extends Env = Env, basePath extends string = '/', + // + _state = env['State'], > = Pick & { /** * The base path for assets. @@ -120,7 +122,7 @@ export type FrogConstructorParameters< * } * ``` */ - initialState?: state | undefined + initialState?: _state | undefined /** * Key used to sign secret data. * @@ -180,14 +182,15 @@ export type RouteOptions = Pick * ``` */ export class FrogBase< - state = undefined, env extends Env = Env, schema extends Schema = {}, basePath extends string = '/', + // + _state = env['State'], > { // Note: not using native `private` fields to avoid tslib being injected // into bundled code. - _initialState: state = undefined as state + _initialState: env['State'] = undefined as env['State'] /** Path for assets. */ assetsPath: string @@ -219,7 +222,7 @@ export class FrogBase< /** Whether or not frames should be verified. */ verify: FrogConstructorParameters['verify'] = true - transaction = transaction + transaction = transaction as typeof transaction constructor({ assetsPath, @@ -235,7 +238,7 @@ export class FrogBase< initialState, secret, verify, - }: FrogConstructorParameters = {}) { + }: FrogConstructorParameters = {}) { this.hono = new Hono(honoOptions) if (basePath) this.hono = this.hono.basePath(basePath) if (browserLocation) this.browserLocation = browserLocation @@ -261,7 +264,7 @@ export class FrogBase< frame( path: path, handler: ( - context: Pretty>, + context: Pretty>, ) => HandlerResponse, options: RouteOptions = {}, ) { @@ -273,8 +276,8 @@ export class FrogBase< const assetsUrl = url.origin + parsePath(this.assetsPath) const baseUrl = url.origin + parsePath(this.basePath) - const context = getFrameContext({ - context: await requestToContext(c.req, { + const { context, getState } = getFrameContext({ + context: await requestBodyToContext(c, { hub: this.hub || (this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined), @@ -283,7 +286,6 @@ export class FrogBase< }), cycle: 'main', initialState: this._initialState, - req: c.req, }) if (context.url !== parsePath(c.req.url)) return c.redirect(context.url) @@ -327,13 +329,15 @@ export class FrogBase< // The OG route also needs context, so we will need to pass the current derived context, // via a query parameter to the OG image route (/image). - const baseContext = { + const queryContext: FrameQueryContext = { ...context, - // We can't serialize `request` (aka `c.req`), so we'll just set it to undefined. - request: undefined, - state: context.getState(), + // `c.req` is not serializable. + req: undefined, + state: getState(), + // `c.var` is not serializable. + var: undefined, } - const frameImageParams = toSearchParams(baseContext) + const frameImageParams = toSearchParams(queryContext) // Derive the previous state, and sign it if a secret is provided. const previousState = await (async () => { @@ -446,7 +450,7 @@ export class FrogBase< {isDevEnabled && ( )} @@ -459,16 +463,13 @@ export class FrogBase< // OG Image Route this.hono.get(`${parsePath(path)}/image`, async (c) => { - const query = c.req.query() - const queryContext = fromQuery< - FrameContext & { state: state } - >(query) - const context = getFrameContext({ - context: queryContext, + const baseContext = requestQueryToContext(c) + + const { context } = getFrameContext({ + context: baseContext, cycle: 'image', initialState: this._initialState, - req: c.req, - state: queryContext?.state, + state: baseContext?.state, }) const defaultImageOptions = @@ -494,10 +495,9 @@ export class FrogBase< route< subPath extends string, - subEnv extends Env, subSchema extends Schema, subBasePath extends string, - >(path: subPath, frog: FrogBase) { + >(path: subPath, frog: FrogBase) { if (frog.assetsPath === '/') frog.assetsPath = this.assetsPath if (frog.basePath === '/') frog.basePath = parsePath(this.basePath) + parsePath(path) diff --git a/src/frog.tsx b/src/frog.tsx index d8a7dc28..571fd2b8 100644 --- a/src/frog.tsx +++ b/src/frog.tsx @@ -1,8 +1,9 @@ -import { type Env, type Schema } from 'hono' +import { type Schema } from 'hono' import { routes as devRoutes } from './dev/routes.js' import { FrogBase, type RouteOptions } from './frog-base.js' import { type FrameContext } from './types/context.js' +import type { Env } from './types/env.js' import { type FrameResponse } from './types/frame.js' import type { HandlerResponse } from './types/response.js' import { type Pretty } from './types/utils.js' @@ -38,19 +39,20 @@ import { type Pretty } from './types/utils.js' * ``` */ export class Frog< - state = undefined, env extends Env = Env, schema extends Schema = {}, basePath extends string = '/', -> extends FrogBase { + // + _state = env['State'], +> extends FrogBase { override frame( path: path, handler: ( - context: Pretty>, + context: Pretty>, ) => HandlerResponse, options: RouteOptions = {}, ) { - super.frame(path, handler, options) + super.frame(path, handler as any, options) if (this.dev?.enabled ?? true) devRoutes(this, path) } diff --git a/src/routes/transaction.ts b/src/routes/transaction.ts index dac76c40..fefdd1d8 100644 --- a/src/routes/transaction.ts +++ b/src/routes/transaction.ts @@ -1,26 +1,32 @@ -import type { Env } from 'hono' - +import type { Schema } from 'hono' import type { FrogBase, RouteOptions } from '../frog-base.js' import type { TransactionContext } from '../types/context.js' +import type { Env } from '../types/env.js' import type { HandlerResponse } from '../types/response.js' import type { TransactionResponse } from '../types/transaction.js' import { getTransactionContext } from '../utils/getTransactionContext.js' import { parsePath } from '../utils/parsePath.js' -import { requestToContext } from '../utils/requestToContext.js' +import { requestBodyToContext } from '../utils/requestBodyToContext.js' -export function transaction( - this: FrogBase, +export function transaction< + env extends Env, + schema extends Schema, + basePath extends string, + // + _state = env['State'], +>( + this: FrogBase, path: string, handler: ( - context: TransactionContext, + context: TransactionContext, ) => HandlerResponse, options: RouteOptions = {}, ) { const { verify = this.verify } = options this.hono.post(parsePath(path), async (c) => { - const transactionContext = getTransactionContext({ - context: await requestToContext(c.req, { + const { context } = getTransactionContext({ + context: await requestBodyToContext(c, { hub: this.hub || (this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined), secret: this.secret, @@ -28,7 +34,7 @@ export function transaction( }), req: c.req, }) - const response = await handler(transactionContext) + const response = await handler(context) if (response instanceof Response) return response return c.json(response.data) }) diff --git a/src/types/context.ts b/src/types/context.ts index 7d6cf0f3..9768fe05 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,4 +1,5 @@ -import type { Context as Context_hono, Env } from 'hono' +import type { Context as Context_hono } from 'hono' +import type { Env } from './env.js' import type { FrameButtonValue, FrameData, FrameResponseFn } from './frame.js' import type { ContractTransactionResponseFn, @@ -8,7 +9,12 @@ import type { } from './transaction.js' import type { Pretty } from './utils.js' -export type Context = { +export type Context< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = { /** * Index of the button that was interacted with on the previous frame. */ @@ -37,7 +43,13 @@ export type Context = { /** * State from the previous frame. */ - previousState: state + previousState: _state + /** + * Hono request object. + * + * @see https://hono.dev/api/context#req + */ + req: Context_hono['req'] /** * Status of the frame in the frame lifecycle. * - `initial` - The frame has not yet been interacted with. @@ -45,6 +57,12 @@ export type Context = { * - `response` - The frame has been interacted with (user presses button). */ status: 'initial' | 'redirect' | 'response' + /** + * 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'] /** * Whether or not the {@link Context`frameData`} was verified by the Farcaster Hub API. */ @@ -56,9 +74,11 @@ export type Context = { } export type FrameContext< + env extends Env = Env, path extends string = string, - state = unknown, -> = Context & { + // + _state = env['State'], +> = Context & { /** * Current render cycle of the frame. * @@ -70,10 +90,7 @@ export type FrameContext< * Function to derive the frame's state based off the state from the * previous frame. */ - deriveState: (fn?: (previousState: state) => void) => state - getState: () => state - /** Frame request object. */ - req: Context_hono['req'] + deriveState: (fn?: (previousState: _state) => void) => _state /** Frame response that includes frame properties such as: image, intents, action, etc */ res: FrameResponseFn /** @@ -83,10 +100,23 @@ export type FrameContext< transactionId?: FrameData['transactionId'] | undefined } +export type FrameQueryContext< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = Omit, 'req' | 'var'> & { + req: undefined + state: _state + var: undefined +} + export type TransactionContext< + env extends Env = Env, path extends string = string, - state = unknown, -> = Context & { + // + _state = env['State'], +> = Context & { /** * Contract transaction request. * @@ -94,8 +124,6 @@ export type TransactionContext< * with a type-safe interface to infer types based on a provided `abi`. */ contract: ContractTransactionResponseFn - /** Frame request object. */ - req: Context_hono['req'] /** * Raw transaction request. * diff --git a/src/types/env.ts b/src/types/env.ts new file mode 100644 index 00000000..a240f5db --- /dev/null +++ b/src/types/env.ts @@ -0,0 +1,5 @@ +import type { Env as Env_hono } from 'hono' + +export type Env = Env_hono & { + State?: unknown | undefined +} diff --git a/src/utils/getFrameContext.ts b/src/utils/getFrameContext.ts index feaba2d3..6117036a 100644 --- a/src/utils/getFrameContext.ts +++ b/src/utils/getFrameContext.ts @@ -1,22 +1,41 @@ -import { type HonoRequest } from 'hono' import { produce } from 'immer' import type { Context, FrameContext } from '../types/context.js' +import type { Env } from '../types/env.js' import { getIntentState } from './getIntentState.js' import { parsePath } from './parsePath.js' -type GetFrameContextParameters = { - context: Context +type GetFrameContextParameters< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = { + context: Context cycle: FrameContext['cycle'] - initialState?: state - req: HonoRequest - state?: state + initialState?: _state + state?: _state } -export function getFrameContext( - parameters: GetFrameContextParameters, -): FrameContext { - const { context, cycle, req, state } = parameters - const { frameData, initialPath, previousButtonValues, verified } = +type GetFrameContextReturnType< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = { + context: FrameContext + getState: () => _state +} + +export function getFrameContext< + env extends Env, + path extends string, + // + _state = env['State'], +>( + parameters: GetFrameContextParameters, +): GetFrameContextReturnType { + const { context, cycle, state } = parameters + const { frameData, initialPath, previousButtonValues, req, verified } = context || {} const { buttonValue, inputText, redirect, reset } = getIntentState({ @@ -41,30 +60,33 @@ export function getFrameContext( return context?.previousState || parameters.initialState })() - function deriveState(derive?: (state: state) => void): state { + function deriveState(derive?: (state: _state) => void): _state { if (status === 'response' && derive) { - if (cycle === 'image') return state as state + if (cycle === 'image') return state as _state previousState = produce(previousState, derive) } - return previousState as state + return previousState as _state } return { - buttonIndex: frameData?.buttonIndex, - buttonValue, - cycle, - frameData, - initialPath, - inputText, - deriveState, - getState: () => previousState as state, - previousButtonValues, - previousState: previousState as any, - req, - res: (data) => ({ data, format: 'frame' }), - status, - transactionId: frameData?.transactionId, - url, - verified, + context: { + buttonIndex: frameData?.buttonIndex, + buttonValue, + cycle, + deriveState, + frameData, + initialPath, + inputText, + previousButtonValues, + previousState: previousState as any, + req, + res: (data) => ({ data, format: 'frame' }), + status, + transactionId: frameData?.transactionId, + url, + var: context.var, + verified, + }, + getState: () => previousState as _state, } } diff --git a/src/utils/getTransactionContext.ts b/src/utils/getTransactionContext.ts index d58f0000..420f0510 100644 --- a/src/utils/getTransactionContext.ts +++ b/src/utils/getTransactionContext.ts @@ -8,23 +8,44 @@ import { getAbiItem, } from 'viem' import type { Context, TransactionContext } from '../types/context.js' +import type { Env } from '../types/env.js' import type { TransactionResponse } from '../types/transaction.js' import { getIntentState } from './getIntentState.js' -type GetTransactionContextParameters = { - context: Context +type GetTransactionContextParameters< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = { + context: Context req: HonoRequest } -export function getTransactionContext( - parameters: GetTransactionContextParameters, -): TransactionContext { - const { context, req } = parameters +type GetTransactionContextReturnType< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = { + context: TransactionContext +} + +export function getTransactionContext< + env extends Env, + path extends string, + // + _state = env['State'], +>( + parameters: GetTransactionContextParameters, +): GetTransactionContextReturnType { + const { context } = parameters const { frameData, initialPath, previousButtonValues, previousState, + req, status, verified, url, @@ -36,62 +57,67 @@ export function getTransactionContext( }) return { - buttonIndex: frameData?.buttonIndex, - buttonValue, - contract(parameters) { - const { abi, chainId, functionName, to, args, value } = parameters + context: { + buttonIndex: frameData?.buttonIndex, + buttonValue, + contract(parameters) { + const { abi, chainId, functionName, to, args, value } = parameters - const abiItem = getAbiItem({ - abi: abi, - name: functionName, - args, - } as GetAbiItemParameters) - if (!abiItem) throw new AbiFunctionNotFoundError(functionName) + const abiItem = getAbiItem({ + abi: abi, + name: functionName, + args, + } as GetAbiItemParameters) + if (!abiItem) throw new AbiFunctionNotFoundError(functionName) - const abiErrorItems = (abi as Abi).filter((item) => item.type === 'error') + const abiErrorItems = (abi as Abi).filter( + (item) => item.type === 'error', + ) - return this.send({ - abi: [abiItem, ...abiErrorItems], - chainId, - data: encodeFunctionData({ - abi, - args, - functionName, - } as EncodeFunctionDataParameters), - to, - value, - }) - }, - frameData, - initialPath, - inputText, - previousButtonValues, - previousState, - req, - res(parameters) { - const { chainId, method, params } = parameters - const { abi, data, to, value } = params - const response: TransactionResponse = { - chainId, - method, - params: { - abi, - data, + return this.send({ + abi: [abiItem, ...abiErrorItems], + chainId, + data: encodeFunctionData({ + abi, + args, + functionName, + } as EncodeFunctionDataParameters), to, - }, - } - if (value) response.params.value = value.toString() - return { data: response, format: 'transaction' } - }, - send(parameters) { - return this.res({ - chainId: parameters.chainId, - method: 'eth_sendTransaction', - params: parameters, - }) + value, + }) + }, + frameData, + initialPath, + inputText, + previousButtonValues, + previousState, + req, + res(parameters) { + const { chainId, method, params } = parameters + const { abi, data, to, value } = params + const response: TransactionResponse = { + chainId, + method, + params: { + abi, + data, + to, + }, + } + if (value) response.params.value = value.toString() + return { data: response, format: 'transaction' } + }, + send(parameters) { + return this.res({ + chainId: parameters.chainId, + method: 'eth_sendTransaction', + params: parameters, + }) + }, + status, + var: context.var, + verified, + url, }, - status, - verified, - url, } } diff --git a/src/utils/requestToContext.ts b/src/utils/requestBodyToContext.ts similarity index 63% rename from src/utils/requestToContext.ts rename to src/utils/requestBodyToContext.ts index 1290a014..3b6e9f2b 100644 --- a/src/utils/requestToContext.ts +++ b/src/utils/requestBodyToContext.ts @@ -1,26 +1,37 @@ -import { type HonoRequest } from 'hono' +import { type Context as Context_hono } from 'hono' import type { FrogConstructorParameters } from '../frog-base.js' import type { Context } from '../types/context.js' +import type { Env } from '../types/env.js' import type { Hub } from '../types/hub.js' import { deserializeJson } from './deserializeJson.js' import { fromQuery } from './fromQuery.js' import * as jws from './jws.js' import { verifyFrame } from './verifyFrame.js' -type RequestToContextOptions = { +type RequestBodyToContextOptions = { hub?: Hub | undefined secret?: FrogConstructorParameters['secret'] verify?: FrogConstructorParameters['verify'] } -type RequestToContextReturnType = Context +type RequestBodyToContextReturnType< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = Context -export async function requestToContext( - req: HonoRequest, - { hub, secret, verify = true }: RequestToContextOptions, -): Promise> { +export async function requestBodyToContext< + env extends Env, + path extends string, + // + _state = env['State'], +>( + c: Context_hono, + { hub, secret, verify = true }: RequestBodyToContextOptions, +): Promise> { const { trustedData, untrustedData } = - (await req.json().catch(() => {})) || {} + (await c.req.json().catch(() => {})) || {} const { initialPath, previousState, previousButtonValues } = await (async () => { if (untrustedData?.state) { @@ -31,7 +42,7 @@ export async function requestToContext( ) return state } - if (req.query()) return fromQuery(req.query()) + if (c.req.query()) return fromQuery(c.req.query()) return {} as any })() @@ -44,7 +55,7 @@ export async function requestToContext( hub, frameUrl: untrustedData.url, trustedData, - url: req.url, + url: c.req.url, }) return { ...frameData, state: frameData.state || untrustedData.state } } catch (err) { @@ -54,12 +65,14 @@ export async function requestToContext( })() return { - initialPath: initialPath ? initialPath : new URL(req.url).pathname, + initialPath: initialPath ? initialPath : new URL(c.req.url).pathname, previousState, previousButtonValues, frameData: trustedFrameData || untrustedData, - status: req.method === 'POST' ? 'response' : 'initial', - url: req.url, + req: c.req, + status: c.req.method === 'POST' ? 'response' : 'initial', + url: c.req.url, + var: c.var, verified: Boolean(trustedFrameData), } } diff --git a/src/utils/requestQueryToContext.ts b/src/utils/requestQueryToContext.ts new file mode 100644 index 00000000..a49e0a05 --- /dev/null +++ b/src/utils/requestQueryToContext.ts @@ -0,0 +1,32 @@ +import { type Context as Context_hono } from 'hono' +import type { Context, FrameQueryContext } from '../types/context.js' +import type { Env } from '../types/env.js' +import { fromQuery } from './fromQuery.js' + +type RequestQueryToContextReturnType< + env extends Env = Env, + path extends string = string, + // + _state = env['State'], +> = Context & { + state: _state +} + +export function requestQueryToContext< + env extends Env, + path extends string, + // + _state = env['State'], +>( + c: Context_hono, +): RequestQueryToContextReturnType { + const query = c.req.query() + + const queryContext = fromQuery>(query) + + return { + ...queryContext, + req: c.req, + var: c.var, + } +} diff --git a/src/vercel/handle.ts b/src/vercel/handle.ts index c9e5e617..a447514d 100644 --- a/src/vercel/handle.ts +++ b/src/vercel/handle.ts @@ -2,6 +2,6 @@ import { handle as handle_hono } from 'hono/vercel' import type { FrogBase } from '../frog-base.js' -export function handle(app: FrogBase) { +export function handle(app: FrogBase) { return handle_hono(app.hono).bind(app.hono) } diff --git a/tsconfig.json b/tsconfig.json index 0da2469b..4d8ee155 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ // This configuration is used for local development and type checking. "extends": "./tsconfig.base.json", "exclude": ["templates/*", "create-frog/templates"], - "include": ["create-frog", "examples", "templates", "src"], + "include": ["create-frog", "examples", "playground", "src"], "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx" From b7031ff4f045a9539fb1a20899b35b41eb26515b Mon Sep 17 00:00:00 2001 From: jxom Date: Wed, 6 Mar 2024 15:20:55 +1100 Subject: [PATCH 19/20] feat: neynar middleware (#87) * tweaks: refactor for middleware * tweaks * feat: neynar middleware * feat: export types * docs: neynar * chore: changeset --- .changeset/green-carrots-hide.md | 5 + playground/src/index.tsx | 2 + playground/src/neynar.tsx | 58 +++++++++ site/pages/concepts/middleware.mdx | 58 +++++++++ src/frog-base.tsx | 2 - src/middlewares/index.ts | 7 ++ src/middlewares/neynar.ts | 186 +++++++++++++++++++++++++++++ src/middlewares/package.json | 5 + src/package.json | 9 +- src/types/context.ts | 3 +- src/utils/requestQueryToContext.ts | 1 - src/utils/verifyFrame.ts | 2 +- 12 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 .changeset/green-carrots-hide.md create mode 100644 playground/src/neynar.tsx create mode 100644 src/middlewares/index.ts create mode 100644 src/middlewares/neynar.ts create mode 100644 src/middlewares/package.json diff --git a/.changeset/green-carrots-hide.md b/.changeset/green-carrots-hide.md new file mode 100644 index 00000000..e26af995 --- /dev/null +++ b/.changeset/green-carrots-hide.md @@ -0,0 +1,5 @@ +--- +"frog": minor +--- + +Added built-in middleware for Neynar. [Read more.](https://frog.fm/concepts/middleware#neynar) diff --git a/playground/src/index.tsx b/playground/src/index.tsx index a58f1885..75fd0300 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -2,6 +2,7 @@ import { Button, Frog, TextInput } from 'frog' import * as hubs from 'frog/hubs' import { app as middlewareApp } from './middleware.js' +import { app as neynarApp } from './neynar.js' import { app as routingApp } from './routing.js' import { app as todoApp } from './todos.js' import { app as transactionApp } from './transaction.js' @@ -228,6 +229,7 @@ app.frame('/redirect-buttons', (c) => { }) app.route('/middleware', middlewareApp) +app.route('/neynar', neynarApp) app.route('/routing', routingApp) app.route('/transaction', transactionApp) app.route('/todos', todoApp) diff --git a/playground/src/neynar.tsx b/playground/src/neynar.tsx new file mode 100644 index 00000000..5f1ce6da --- /dev/null +++ b/playground/src/neynar.tsx @@ -0,0 +1,58 @@ +import { Button, Frog } from 'frog' +import { type NeynarVariables, neynar } from 'frog/middlewares' + +export const app = new Frog<{ + Variables: NeynarVariables +}>() + +app.use( + neynar({ + apiKey: 'NEYNAR_FROG_FM', + features: ['interactor', 'cast'], + }), +) + +app.frame('/', (c) => { + return c.res({ + action: '/guess', + image: ( +
+ I can guess your name and follower count. +
+ ), + intents: [], + }) +}) + +app.frame('/guess', (c) => { + const { displayName, followerCount } = c.var.interactor || {} + console.log('interactor: ', c.var.interactor) + console.log('cast: ', c.var.cast) + return c.res({ + image: ( +
+ Greetings {displayName}, you have {followerCount} followers. +
+ ), + }) +}) diff --git a/site/pages/concepts/middleware.mdx b/site/pages/concepts/middleware.mdx index cdfeb8f5..d81e34d7 100644 --- a/site/pages/concepts/middleware.mdx +++ b/site/pages/concepts/middleware.mdx @@ -4,6 +4,64 @@ Since Frog is built on top of [Hono](https://hono.dev), Frog supports Hono's mid Middleware works before and after the `.frame` [handler](/reference/frog-frame#handler) by allowing you to manipulate the request and response before and after dispatching respectively. We recommend checking out the Hono [documentation on Middleware](https://hono.dev/guides/middleware) for a more in-depth understanding. +## Built-in Middlewares + +### Neynar + +Frog comes with a built-in middleware for [Neynar](https://neynar.com) which allows you to easily integrate Neynar features (such as the interactor of your frame, and frame cast) into Frog context. + +```tsx twoslash +/** @jsxImportSource frog/jsx */ +// ---cut--- +import { Button, Frog } from 'frog' +import { type NeynarVariables, neynar } from 'frog/middlewares' + +export const app = new Frog<{ + Variables: NeynarVariables +// @log: ↑ 1. Inject variables to get type inference on context (`c.var`). +}>() + +// @log: ↓ 2. Inject `neynar` middleware onto the app. +app.use( + neynar({ + apiKey: 'NEYNAR_FROG_FM', + features: ['interactor', 'cast'], + }), +) + +app.frame('/', (c) => { +// @log: ↓ 3. Use `c.var` to access Neynar variables! + const { displayName, followerCount } = c.var.interactor || {} + console.log('cast: ', c.var.cast) + console.log('interactor: ', c.var.interactor) + // ^? + return c.res({ + image: ( +
+ Greetings {displayName}, you have {followerCount} followers. +
+ ), + }) +}) +``` + +:::warning +Feel free to use our Neynar API Key: `"NEYNAR_FROG_FM"`. + +However, please note that this API Key is for development purposes only – it is prone to rate-limiting. +It is recommended to use your own API Key in production. [See more](https://neynar.com/#get-started). +::: + ## Custom Middleware You can write your own Frog middleware. This is great if you want to share common logic across or frames or if you are developing a SDK for Frog users to hook into their frames. diff --git a/src/frog-base.tsx b/src/frog-base.tsx index 76b0a67e..46a9f960 100644 --- a/src/frog-base.tsx +++ b/src/frog-base.tsx @@ -334,8 +334,6 @@ export class FrogBase< // `c.req` is not serializable. req: undefined, state: getState(), - // `c.var` is not serializable. - var: undefined, } const frameImageParams = toSearchParams(queryContext) diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts new file mode 100644 index 00000000..54342e52 --- /dev/null +++ b/src/middlewares/index.ts @@ -0,0 +1,7 @@ +export { + neynar, + type NeynarVariables, + type NeynarCast, + type NeynarMiddlewareParameters, + type NeynarUser, +} from './neynar.js' diff --git a/src/middlewares/neynar.ts b/src/middlewares/neynar.ts new file mode 100644 index 00000000..0e3d3a86 --- /dev/null +++ b/src/middlewares/neynar.ts @@ -0,0 +1,186 @@ +import type { MiddlewareHandler } from 'hono' +import { hexToBytes } from 'viem' +import { Message } from '../protobufs/generated/message_pb.js' +import type { Pretty } from '../types/utils.js' +import { messageToFrameData } from '../utils/verifyFrame.js' + +export type NeynarVariables = { + /** + * The cast of the frame. + */ + cast?: Pretty | undefined + /** + * The user who interacted with the frame. + */ + interactor?: Pretty | undefined +} + +export type NeynarMiddlewareParameters = { + /** + * Neynar API Key. + */ + apiKey: string + /** + * Set of features to enable and inject into context. + * + * - `interactor`: Fetches the user who interacted with the frame. + * - `cast`: Fetches the cast of the frame. + */ + features: ('interactor' | 'cast')[] +} + +export function neynar( + parameters: NeynarMiddlewareParameters, +): MiddlewareHandler<{ + Variables: NeynarVariables +}> { + const { apiKey, features } = parameters + return async (c, next) => { + const { trustedData } = (await c.req.json().catch(() => {})) || {} + if (!trustedData) return await next() + + // Note: We are not verifying here as we verify downstream (internal Frog handler). + const body = hexToBytes(`0x${trustedData.messageBytes}`) + const message = Message.fromBinary(body) + const frameData = messageToFrameData(message) + + const { + castId: { fid: castFid, hash }, + fid, + } = frameData + + const [castResponse, usersResponse] = await Promise.all([ + features.includes('cast') + ? getCast({ + apiKey, + hash, + }) + : Promise.resolve(undefined), + features.includes('interactor') + ? getUsers({ apiKey, castFid, fids: [fid] }) + : Promise.resolve(undefined), + ]) + + if (castResponse) c.set('cast', castResponse.cast) + if (usersResponse) { + const [user] = usersResponse.users + if (user) c.set('interactor', user) + } + + await next() + } +} + +/////////////////////////////////////////////////////////////////////////// +// Utilities + +const neynarApiUrl = 'https://api.neynar.com' + +type GetCastParameters = { apiKey: string; hash: string } +type GetCastReturnType = { + cast: NeynarCast +} + +async function getCast({ + apiKey, + hash, +}: GetCastParameters): Promise { + const response = await fetch( + `${neynarApiUrl}/v2/farcaster/cast?type=hash&identifier=${hash}`, + { + headers: { + api_key: apiKey, + 'Content-Type': 'application/json', + }, + }, + ).then((res) => res.json()) + return camelCaseKeys(response) as GetCastReturnType +} + +type GetUsersParameters = { apiKey: string; castFid: number; fids: number[] } +type GetUsersReturnType = { + users: NeynarUser[] +} + +async function getUsers({ + apiKey, + castFid, + fids, +}: GetUsersParameters): Promise { + const response = await fetch( + `${neynarApiUrl}/v2/farcaster/user/bulk?fids=${fids.join( + ',', + )}&viewer_fid=${castFid}`, + { + headers: { + api_key: apiKey, + 'Content-Type': 'application/json', + }, + }, + ).then((res) => res.json()) + return camelCaseKeys(response) as GetUsersReturnType +} + +function camelCaseKeys(response: object): object { + if (!response) return response + if (typeof response !== 'object') return response + if (Array.isArray(response)) return response.map(camelCaseKeys) + return Object.fromEntries( + Object.entries(response).map(([key, value]) => [ + key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), + camelCaseKeys(value), + ]), + ) +} + +/////////////////////////////////////////////////////////////////////////// +// Types + +export type NeynarCast = { + author: NeynarUser + embeds: { url: string }[] + // TODO: populate with real type. + frames: unknown + hash: string + mentionedProfiles: NeynarUser[] + object: 'cast' + parentAuthor: { fid: number | null } + parentHash: string | null + parentUrl: string + reactions: { + likes: { fid: number; fname: string }[] + recasts: { fid: number; fname: string }[] + } + replies: { count: number } + rootParentUrl: string + text: string + threadHash: string + timestamp: string +} + +export type NeynarUser = { + activeStatus: 'active' | 'inactive' + custodyAddress: string + displayName: string + fid: number + followerCount: number + followingCount: number + object: 'user' + pfpUrl: string + profile: { + bio: { + text: string + mentionedProfiles: string[] + } + } + username: string + verifications: string[] + verifiedAddresses: { + ethAddresses: string[] + solAddresses: string[] + } + viewerContext?: { + following: boolean + followedBy: boolean + } +} diff --git a/src/middlewares/package.json b/src/middlewares/package.json new file mode 100644 index 00000000..b5da32e7 --- /dev/null +++ b/src/middlewares/package.json @@ -0,0 +1,5 @@ +{ + "type": "module", + "types": "../_lib/middlewares/index.d.ts", + "module": "../_lib/middlewares/index.js" +} diff --git a/src/package.json b/src/package.json index 3fb60223..e2bacd16 100644 --- a/src/package.json +++ b/src/package.json @@ -39,6 +39,10 @@ "types": "./_lib/jsx/jsx-dev-runtime/index.d.ts", "default": "./_lib/jsx/jsx-dev-runtime/index.js" }, + "./middlewares": { + "types": "./_lib/middlewares/index.d.ts", + "default": "./_lib/middlewares/index.js" + }, "./next": { "types": "./_lib/next/index.d.ts", "default": "./_lib/next/index.js" @@ -80,10 +84,7 @@ "license": "MIT", "homepage": "https://frog.fm", "repository": "wevm/frog", - "authors": [ - "awkweb.eth", - "jxom.eth" - ], + "authors": ["awkweb.eth", "jxom.eth"], "funding": [ { "type": "github", diff --git a/src/types/context.ts b/src/types/context.ts index 9768fe05..4338550f 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -105,10 +105,9 @@ export type FrameQueryContext< path extends string = string, // _state = env['State'], -> = Omit, 'req' | 'var'> & { +> = Omit, 'req'> & { req: undefined state: _state - var: undefined } export type TransactionContext< diff --git a/src/utils/requestQueryToContext.ts b/src/utils/requestQueryToContext.ts index a49e0a05..eefbf113 100644 --- a/src/utils/requestQueryToContext.ts +++ b/src/utils/requestQueryToContext.ts @@ -27,6 +27,5 @@ export function requestQueryToContext< return { ...queryContext, req: c.req, - var: c.var, } } diff --git a/src/utils/verifyFrame.ts b/src/utils/verifyFrame.ts index e37c9b27..1dc578ba 100644 --- a/src/utils/verifyFrame.ts +++ b/src/utils/verifyFrame.ts @@ -46,7 +46,7 @@ export async function verifyFrame({ //////////////////////////////////////////////////////////////////// // Utilties -function messageToFrameData(message: Message): FrameData { +export function messageToFrameData(message: Message): FrameData { const frameActionBody = message.data?.body.value as FrameActionBody const frameData: FrameData = { castId: { From e796d9119b6ffc39040fbf1577e313b1b2978deb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 15:27:29 +1100 Subject: [PATCH 20/20] chore: version packages (#86) Co-authored-by: github-actions[bot] --- .changeset/green-carrots-hide.md | 5 ----- .changeset/odd-hats-rush.md | 14 -------------- .changeset/unlucky-lions-tan.md | 5 ----- src/CHANGELOG.md | 21 +++++++++++++++++++++ src/package.json | 7 +++++-- src/version.ts | 2 +- 6 files changed, 27 insertions(+), 27 deletions(-) delete mode 100644 .changeset/green-carrots-hide.md delete mode 100644 .changeset/odd-hats-rush.md delete mode 100644 .changeset/unlucky-lions-tan.md diff --git a/.changeset/green-carrots-hide.md b/.changeset/green-carrots-hide.md deleted file mode 100644 index e26af995..00000000 --- a/.changeset/green-carrots-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": minor ---- - -Added built-in middleware for Neynar. [Read more.](https://frog.fm/concepts/middleware#neynar) diff --git a/.changeset/odd-hats-rush.md b/.changeset/odd-hats-rush.md deleted file mode 100644 index 5fb26da7..00000000 --- a/.changeset/odd-hats-rush.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"frog": patch ---- - -**Type Change:** The `state` generic in the `Frog` constructor type is now named. - -```diff -type State = { count: number } - -- const frog = new Frog({ -+ const frog = new Frog<{ State: State }>({ - initialState: { count: 0 } -}) -``` diff --git a/.changeset/unlucky-lions-tan.md b/.changeset/unlucky-lions-tan.md deleted file mode 100644 index 755a1a56..00000000 --- a/.changeset/unlucky-lions-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frog": patch ---- - -Added a `var` property to context to extract variables that were previously set via `set` in Middleware. [Read more.](https://frog.fm/reference/frog-frame-context#var) diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 725179a7..23337fa6 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,26 @@ # frog +## 0.4.0 + +### Minor Changes + +- [#87](https://github.com/wevm/frog/pull/87) [`b7031ff`](https://github.com/wevm/frog/commit/b7031ff4f045a9539fb1a20899b35b41eb26515b) Thanks [@jxom](https://github.com/jxom)! - Added built-in middleware for Neynar. [Read more.](https://frog.fm/concepts/middleware#neynar) + +### Patch Changes + +- [#80](https://github.com/wevm/frog/pull/80) [`c377528`](https://github.com/wevm/frog/commit/c3775288bc8683d532d9c6ca2cd05e6f2f1bd69d) Thanks [@jxom](https://github.com/jxom)! - **Type Change:** The `state` generic in the `Frog` constructor type is now named. + + ```diff + type State = { count: number } + + - const frog = new Frog({ + + const frog = new Frog<{ State: State }>({ + initialState: { count: 0 } + }) + ``` + +- [#80](https://github.com/wevm/frog/pull/80) [`c377528`](https://github.com/wevm/frog/commit/c3775288bc8683d532d9c6ca2cd05e6f2f1bd69d) Thanks [@jxom](https://github.com/jxom)! - Added a `var` property to context to extract variables that were previously set via `set` in Middleware. [Read more.](https://frog.fm/reference/frog-frame-context#var) + ## 0.3.3 ### Patch Changes diff --git a/src/package.json b/src/package.json index e2bacd16..4ae8adc5 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "frog", "description": "Framework for Farcaster Frames", - "version": "0.3.3", + "version": "0.4.0", "type": "module", "module": "_lib/index.js", "types": "_lib/index.d.ts", @@ -84,7 +84,10 @@ "license": "MIT", "homepage": "https://frog.fm", "repository": "wevm/frog", - "authors": ["awkweb.eth", "jxom.eth"], + "authors": [ + "awkweb.eth", + "jxom.eth" + ], "funding": [ { "type": "github", diff --git a/src/version.ts b/src/version.ts index 5c54b771..02968f11 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.3.3' +export const version = '0.4.0'