Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: mini-app transaction requests and response listening #503

Merged
merged 14 commits into from
Oct 24, 2024
13 changes: 13 additions & 0 deletions .changeset/red-books-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"frog": minor
---

**Breaking Change**: `frog/vercel` was deleted. If you used `handle` from this package, import it from `frog/next`.
**Breaking Change:** `frog/next` no longer exports `postComposerCreateCastActionMessage`. Use `createCast` from `frog/web`.

Introduced `frog/web` for client-side related logic in favor of `frog/next`.
For backwards compatibility, all the previous exports are kept, but will be
deprecated in future, except for NextJS related `handle` function.

Added functionality for the Mini-App JSON-RPC requests. [See more](https://warpcast.notion.site/Miniapp-Transactions-1216a6c0c10180b7b9f4eec58ec51e55).
Added `createCast`, `sendTransaction`, `contractTransaction` and `signTypedData` to `frog/web`.
2 changes: 1 addition & 1 deletion services/frame/api/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button, Frog } from 'frog'
import { devtools } from 'frog/dev'
import { handle } from 'frog/next'
import { serveStatic } from 'frog/serve-static'
import { handle } from 'frog/vercel'

type State = {
featureIndex: number
Expand Down
6 changes: 3 additions & 3 deletions site/pages/concepts/composer-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ app.composerAction(
```

### Client-Side Helpers
Frog exports `postComposerCreateCastActionMessage` helper to post the message to the `window.parent`.
Frog exports `createCast` helper to post the message to the `window.parent`.

```tsx twoslash [src/index.tsx]
// @noErrors
import { postComposerCreateCastActionMessage } from 'frog/next'
import { createCast } from 'frog/web'

function App() {
return (
<button onClick={() => postComposerCreateCastActionMessage({/**/})}>
<button onClick={() => createCast({/**/})}>
Button
</button>
)
Expand Down
4 changes: 2 additions & 2 deletions site/pages/platforms/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ This leverages the [`generateMetadata`](https://nextjs.org/docs/app/api-referenc

```tsx twoslash [app/page.tsx]
// @noErrors
import { getFrameMetadata } from 'frog/next'
import { getFrameMetadata } from 'frog/web'
import type { Metadata } from 'next'

export async function generateMetadata(): Promise<Metadata> {
Expand All @@ -291,7 +291,7 @@ If you use suspended components in a page, route Next.js will stream the respons
// @noErrors
import { headers } from 'next/headers'
import type { Metadata } from 'next'
import { getFrameMetadata, isFrameRequest } from 'frog/next'
import { getFrameMetadata, isFrameRequest } from 'frog/web'

import { SuspendedComponent } from './suspense-component'

Expand Down
6 changes: 3 additions & 3 deletions site/pages/platforms/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ After that, we will append Vercel handlers to the file.
/** @jsxImportSource frog/jsx */
// ---cut---
import { Button, Frog } from 'frog'
import { handle } from 'frog/vercel' // [!code focus]
import { handle } from 'frog/next' // [!code focus]

// Uncomment to use Edge Runtime.
// export const config = {
Expand Down Expand Up @@ -135,7 +135,7 @@ Add Frog [Devtools](/concepts/devtools) after all frames are defined. This way t
/** @jsxImportSource frog/jsx */
// ---cut---
import { Button, Frog } from 'frog'
import { handle } from 'frog/vercel'
import { handle } from 'frog/next'
import { devtools } from 'frog/dev' // [!code focus]
import { serveStatic } from 'frog/serve-static' // [!code focus]

Expand Down Expand Up @@ -210,7 +210,7 @@ they will be redirected to the `/` path.
/** @jsxImportSource frog/jsx */
// ---cut---
import { Button, Frog } from 'frog'
import { handle } from 'frog/vercel'
import { handle } from 'frog/next'

// Uncomment to use Edge Runtime.
// export const config = {
Expand Down
2 changes: 1 addition & 1 deletion src/vercel/handle.ts → src/next/handle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Schema } from 'hono'
import { handle as handle_hono } from 'hono/vercel'

import type { Frog } from '../frog.js'
import type { Frog } from '../frog.jsx'
import type { Env } from '../types/env.js'

export function handle<
Expand Down
11 changes: 1 addition & 10 deletions src/next/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1 @@
// TODO: Rename this package to `js` as most of it doesn't strictly depend
// on Next.JS specific features. Only `handle` does.

export { getFrameMetadata } from './getFrameMetadata.js'
export { handle } from '../vercel/index.js'
export { isFrameRequest } from './isFrameRequest.js'
export {
postComposerActionMessage,
postComposerCreateCastActionMessage,
} from './postComposerActionMessage.js'
export { handle } from './handle.js'
37 changes: 0 additions & 37 deletions src/next/postComposerActionMessage.ts

This file was deleted.

11 changes: 4 additions & 7 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@
"types": "./_lib/ui/icons/radix-icons/index.d.ts",
"default": "./_lib/ui/icons/radix-icons/index.js"
},
"./vercel": {
"types": "./_lib/vercel/index.d.ts",
"default": "./_lib/vercel/index.js"
"./web": {
"types": "./_lib/web/index.d.ts",
"default": "./_lib/web/index.js"
}
},
"peerDependencies": {
Expand Down Expand Up @@ -121,10 +121,7 @@
"license": "MIT",
"homepage": "https://frog.fm",
"repository": "wevm/frog",
"authors": [
"awkweb.eth",
"jxom.eth"
],
"authors": ["awkweb.eth", "jxom.eth"],
"funding": [
{
"type": "github",
Expand Down
1 change: 0 additions & 1 deletion src/vercel/index.ts

This file was deleted.

5 changes: 0 additions & 5 deletions src/vercel/package.json

This file was deleted.

68 changes: 68 additions & 0 deletions src/web/actions/contractTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
type Abi,
AbiFunctionNotFoundError,
type ContractFunctionArgs,
type ContractFunctionName,
type EncodeFunctionDataParameters,
type GetAbiItemParameters,
encodeFunctionData,
getAbiItem,
} from 'viem'
import type { ContractTransactionParameters } from '../../types/transaction.js'
import type { JsonRpcResponseError } from './internal/jsonRpc/types.js'
import { postSendTransactionRequestMessage } from './internal/postSendTransactionRequestMessage.js'
import {
type EthSendTransactionSuccessBody,
waitForSendTransactionResponse,
} from './internal/waitForSendTransactionResponse.js'

type ContractTransactionReturnType = EthSendTransactionSuccessBody
type ContractTransactionErrorType = JsonRpcResponseError
export type {
ContractTransactionParameters,
ContractTransactionReturnType,
ContractTransactionErrorType,
}

export async function contractTransaction<
const abi extends Abi | readonly unknown[],
functionName extends ContractFunctionName<abi, 'nonpayable' | 'payable'>,
args extends ContractFunctionArgs<
abi,
'nonpayable' | 'payable',
functionName
>,
>(
parameters: ContractTransactionParameters<abi, functionName, args>,
requestIdOverride?: string,
): Promise<ContractTransactionReturnType> {
const { abi, chainId, functionName, gas, to, args, attribution, 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')

const requestId = postSendTransactionRequestMessage(
{
abi: [abiItem, ...abiErrorItems],
attribution,
chainId,
data: encodeFunctionData({
abi,
args,
functionName,
} as EncodeFunctionDataParameters),
gas,
to,
value,
},
requestIdOverride,
)
return waitForSendTransactionResponse(requestId)
}
20 changes: 20 additions & 0 deletions src/web/actions/createCast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { JsonRpcResponseError } from './internal/jsonRpc/types.js'
import {
type CreateCastRequestMessageParameters,
postCreateCastRequestMessage,
} from './internal/postCreateCastRequestMessage.js'
import type { FcCreateCastSuccessBody } from './internal/waitForCreateCastResponse.js'
import { waitForCreateCastResponse } from './internal/waitForCreateCastResponse.js'

type CreateCastParameters = CreateCastRequestMessageParameters
type CreateCastReturnType = FcCreateCastSuccessBody
type CreateCastErrorType = JsonRpcResponseError
export type { CreateCastParameters, CreateCastReturnType, CreateCastErrorType }

export async function createCast(
parameters: CreateCastParameters,
requestIdOverride?: string,
): Promise<CreateCastReturnType> {
const requestId = postCreateCastRequestMessage(parameters, requestIdOverride)
return waitForCreateCastResponse(requestId)
}
10 changes: 10 additions & 0 deletions src/web/actions/internal/jsonRpc/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class JsonRpcError extends Error {
code: number
requestId: string
constructor(requestId: string, code: number, message: string) {
super(message)
this.name = 'JsonRpcError'
this.code = code
this.requestId = requestId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { JsonRpcResponseFailure, JsonRpcResponseSuccess } from './types.js'

export function listenForJsonRpcResponseMessage<resultType>(
handler: (
message: JsonRpcResponseSuccess<resultType> | JsonRpcResponseFailure,
) => unknown,
requestId: string,
) {
if (typeof window === 'undefined')
throw new Error(
'`listenForJsonRpcResponseMessage` must be called in the Client Component.',
)

const listener = (
event: MessageEvent<
JsonRpcResponseSuccess<resultType> | JsonRpcResponseFailure
>,
) => {
if (event.data.id !== requestId) return

handler(event.data)
}

window.parent.addEventListener('message', listener)

return () => window.parent.removeEventListener('message', listener)
}
26 changes: 26 additions & 0 deletions src/web/actions/internal/jsonRpc/postJsonRpcRequestMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { JsonRpcMethod } from './types.js'

export type PostJsonRpcRequestMessageReturnType = string

export function postJsonRpcRequestMessage(
method: JsonRpcMethod,
parameters: any,
requestIdOverride?: string,
): PostJsonRpcRequestMessageReturnType {
if (typeof window === 'undefined')
throw new Error(
'`postJsonRpcRequestMessage` must be called in the Client Component.',
)

const requestId = requestIdOverride ?? crypto.randomUUID()
window.parent.postMessage(
{
jsonrpc: '2.0',
id: requestId,
method,
params: parameters,
},
'*',
)
return requestId
}
18 changes: 18 additions & 0 deletions src/web/actions/internal/jsonRpc/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type JsonRpcResponseSuccess<resultType> = {
jsonrpc: '2.0'
id: string | number | null
result: resultType
}

export type JsonRpcResponseError = {
code: number
message: string
}

export type JsonRpcResponseFailure = {
jsonrpc: '2.0'
id: string | number | null
error: JsonRpcResponseError
}

export type JsonRpcMethod = 'fc_requestWalletAction' | 'fc_createCast'
18 changes: 18 additions & 0 deletions src/web/actions/internal/jsonRpc/waitForJsonRpcResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { JsonRpcError } from './errors.js'
import { listenForJsonRpcResponseMessage } from './listenForJsonRpcResponseMessage.js'

export function waitForJsonRpcResponse<resultType>(
requestId: string,
): Promise<resultType> {
return new Promise<resultType>((resolve, reject) => {
listenForJsonRpcResponseMessage<resultType>((message) => {
if ('result' in message) {
resolve(message.result)
return
}
reject(
new JsonRpcError(requestId, message.error.code, message.error.message),
)
}, requestId)
})
}
23 changes: 23 additions & 0 deletions src/web/actions/internal/postCreateCastRequestMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
type PostJsonRpcRequestMessageReturnType,
postJsonRpcRequestMessage,
} from './jsonRpc/postJsonRpcRequestMessage.js'

export type CreateCastRequestMessageParameters = {
embeds: string[]
text: string
}

export type CreateCastRequestMessageReturnType =
PostJsonRpcRequestMessageReturnType

export function postCreateCastRequestMessage(
parameters: CreateCastRequestMessageParameters,
requestIdOverride?: string,
) {
return postJsonRpcRequestMessage(
'fc_createCast',
parameters,
requestIdOverride,
)
}
Loading
Loading