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: framegear can test some frames locally #152

Merged
merged 7 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions framegear/app/api/postFrame/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest } from 'next/server';
import { getMockFrameRequest } from '@coinbase/onchainkit';

export async function POST(req: NextRequest) {
const data = await req.json();
const { frameData, options } = data;
const postUrl = frameData.url;
const debugPayload = getMockFrameRequest(
{ untrustedData: frameData, trustedData: { messageBytes: '' } },
options,
);

const res = await fetch(postUrl, {
method: 'POST',
body: JSON.stringify(debugPayload),
});

const html = await res.text();

return Response.json({ html });
}
52 changes: 47 additions & 5 deletions framegear/components/Frame/Frame.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { postFrame } from '@/utils/postFrame';
import { frameResultsAtom } from '@/utils/store';
import { useAtom } from 'jotai';
import { PropsWithChildren, useMemo } from 'react';
import { PropsWithChildren, useCallback, useMemo } from 'react';

export function Frame() {
const [results] = useAtom(frameResultsAtom);
Expand All @@ -26,8 +27,15 @@ function ValidFrame({ tags }: { tags: Record<string, string> }) {
// TODO: when debugger is live we will also need to extract actions, etc.
const buttons = [1, 2, 3, 4].map((index) => {
const key = `fc:frame:button:${index}`;
const actionKey = `${key}:action`;
const targetKey = `${key}:target`;
const value = tags[key];
return value ? { key, value } : undefined;
const action = tags[actionKey] || 'post';
const target = tags[targetKey] || tags['fc:frame:post_url'];

// If value exists, we can return the whole object (incl. default values).
// If it doesn't, then the truth is there is no button.
return value ? { key, value, action, target, index } : undefined;
});
return {
image,
Expand All @@ -51,7 +59,11 @@ function ValidFrame({ tags }: { tags: Record<string, string> }) {
)}
<div className="flex flex-wrap gap-4">
{buttons.map((button) =>
button ? <FrameButton key={button.key}>{button.value}</FrameButton> : null,
button ? (
<FrameButton key={button.key} button={button}>
{button.value}
</FrameButton>
) : null,
)}
</div>
</div>
Expand All @@ -77,12 +89,42 @@ function PlaceholderFrame() {
);
}

function FrameButton({ children }: PropsWithChildren<{}>) {
function FrameButton({
children,
button,
}: PropsWithChildren<{
// TODO: this type should probably be extracted
button?: { key: string; value: string; action: string; target: string; index: number };
}>) {
const [_, setResults] = useAtom(frameResultsAtom);
const handleClick = useCallback(async () => {
if (button?.action === 'post') {
// TODO: collect user options (follow, like, etc.) and include
const result = await postFrame({
buttonIndex: button.index,
url: button.target,
// TODO: make these user-input-driven
castId: {
fid: 0,
hash: '0xthisisnotreal',
},
inputText: '',
fid: 0,
messageHash: '0xthisisnotreal',
network: 0,
timestamp: 0,
});
setResults((prev) => [...prev, result]);
return;
}
// TODO: implement other actions
}, [button, setResults]);
return (
<button
className="border-button w-[45%] grow rounded-lg border bg-white p-2 text-black"
type="button"
disabled
onClick={handleClick}
disabled={button?.action !== 'post'}
>
<span>{children}</span>
</button>
Expand Down
1 change: 1 addition & 0 deletions framegear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "jest"
},
"dependencies": {
"@coinbase/onchainkit": "^0.8.0",
"@radix-ui/react-icons": "^1.3.0",
"jotai": "^2.6.4",
"next": "14.1.0",
Expand Down
46 changes: 1 addition & 45 deletions framegear/utils/fetchFrame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { vNextSchema } from './validation';
import { parseHtml } from './parseHtml';

export async function fetchFrame(url: string) {
const response = await fetch('/api/getFrame', {
Expand All @@ -13,47 +13,3 @@ export async function fetchFrame(url: string) {
const html = json.html;
return parseHtml(html);
}

function parseHtml(html: string) {
const document = new DOMParser().parseFromString(html, 'text/html');

const ogImage = document.querySelectorAll(`[property='og:image']`);

// According to spec, keys on the metatags should be on "property", but there are examples
// in the wild where they're on "name". Process name tags first so that property tags take
// precedence.
const frameMetaTagsProperty = document.querySelectorAll(`[property^='fc:frame']`);
const frameMetaTagsName = document.querySelectorAll(`[name^='fc:frame']`);

const nameTags = [...frameMetaTagsName];
const propertyTags = [...ogImage, ...frameMetaTagsProperty];
const tags: Record<string, string> = {};

function processTag(tag: Element, keyName: 'property' | 'name') {
const key = tag.getAttribute(keyName);
const value = tag.getAttribute('content');
if (key && value) {
tags[key] = value;
}
}
nameTags.forEach((t) => processTag(t, 'name'));
propertyTags.forEach((t) => processTag(t, 'property'));

const isValid = vNextSchema.isValidSync(tags);
const errors = aggregateValidationErrors(tags);

return { isValid, errors, tags };
}

function aggregateValidationErrors(tags: Record<string, string>) {
try {
vNextSchema.validateSync(tags, { abortEarly: false });
} catch (e) {
const errors: Record<string, string> = {};
(e as any).inner.forEach((error: any) => {
errors[error.path as string] = error.message;
});
return errors;
}
return {};
}
45 changes: 45 additions & 0 deletions framegear/utils/parseHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { vNextSchema } from './validation';

export function parseHtml(html: string) {
const document = new DOMParser().parseFromString(html, 'text/html');

const ogImage = document.querySelectorAll(`[property='og:image']`);

// According to spec, keys on the metatags should be on "property", but there are examples
// in the wild where they're on "name". Process name tags first so that property tags take
// precedence.
const frameMetaTagsProperty = document.querySelectorAll(`[property^='fc:frame']`);
const frameMetaTagsName = document.querySelectorAll(`[name^='fc:frame']`);

const nameTags = [...frameMetaTagsName];
const propertyTags = [...ogImage, ...frameMetaTagsProperty];
const tags: Record<string, string> = {};

function processTag(tag: Element, keyName: 'property' | 'name') {
const key = tag.getAttribute(keyName);
const value = tag.getAttribute('content');
if (key && value) {
tags[key] = value;
}
}
nameTags.forEach((t) => processTag(t, 'name'));
propertyTags.forEach((t) => processTag(t, 'property'));

const isValid = vNextSchema.isValidSync(tags);
const errors = aggregateValidationErrors(tags);

return { isValid, errors, tags };
}

function aggregateValidationErrors(tags: Record<string, string>) {
try {
vNextSchema.validateSync(tags, { abortEarly: false });
} catch (e) {
const errors: Record<string, string> = {};
(e as any).inner.forEach((error: any) => {
errors[error.path as string] = error.message;
});
return errors;
}
return {};
}
21 changes: 21 additions & 0 deletions framegear/utils/postFrame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { FrameRequest, MockFrameRequestOptions } from '@coinbase/onchainkit';
import { parseHtml } from './parseHtml';

type FrameData = FrameRequest['untrustedData'];

export async function postFrame(frameData: FrameData, options?: MockFrameRequestOptions) {
// TODO: handle exceptional cases
const res = await fetch('/api/postFrame', {
body: JSON.stringify({
frameData,
options,
}),
method: 'POST',
headers: {
contentType: 'application/json',
},
});
const json = await res.json();
const html = json.html;
return parseHtml(html);
}
14 changes: 14 additions & 0 deletions framegear/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,19 @@ __metadata:
languageName: node
linkType: hard

"@coinbase/onchainkit@npm:^0.8.0":
version: 0.8.0
resolution: "@coinbase/onchainkit@npm:0.8.0"
peerDependencies:
graphql: ^14
graphql-request: ^6
react: ^18
react-dom: ^18
viem: ^2.7.0
checksum: 89b38b96bea17b2a7abcb878de53a14327db898415a9e4f18881257db95e8f07091b10c16390be4438445f74cedf8a27dbca3bb96dd071c6810cf666fa6bc201
languageName: node
linkType: hard

"@cspotcode/source-map-support@npm:^0.8.0":
version: 0.8.1
resolution: "@cspotcode/source-map-support@npm:0.8.1"
Expand Down Expand Up @@ -3179,6 +3192,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "framegear@workspace:."
dependencies:
"@coinbase/onchainkit": "npm:^0.8.0"
"@radix-ui/react-icons": "npm:^1.3.0"
"@testing-library/jest-dom": "npm:^6.4.2"
"@testing-library/react": "npm:^14.2.1"
Expand Down
Binary file modified site/.yarn/install-state.gz
Binary file not shown.
72 changes: 72 additions & 0 deletions site/docs/pages/framekit/framegear.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# framegear

`framegear` is a simple tool that allows you to run and test your frames locally:

- without publishing
- without casting
- without spending warps

## Installation and Usage

`framegear` is currently distributed as part of the [coinbase/onchainkit](https://github.com/coinbase/onchainkit.git) Git repository.

```sh
# clone the repo
git clone https://github.com/coinbase/onchainkit.git

# Use Yarn
cd onchainkit/framegear
yarn
yarn dev

# Use NPM
cd onchainkit/framegear
npm install
npm run dev

# Use PNPM
cd onchainkit/framegear
pnpm install
pnpm run dev
```

Visit http://localhost:1337 to start the `framegear` interface. Enter the URL of your locally running
frame (e.g., `http://localhost:3000`) and click `Fetch` to validate your frame response and start testing.

### Frame-specific setup

`framegear` can validate the initial response of any frame. However, more sophisticated debugging is available
if the frame is built using `@coinbase/onchainkit` (versions `^0.8.0`). When calling the `getFrameMessage` function
pass the `allowFramegear` option to allow `framegear` to send mock frame actions.

```ts
const result = await getFrameMessage(body, { allowFramegear: true });
```

#### An Important Security Note

Frames in production should not pass the `allowFramegear` option. Exact setup will depend on the particular application,
but one example is:

```ts
const allowFramegear = process.env.NODE_ENV !== 'production';
// ...
const result = await getFrameMessage(body, { allowFramegear });
```

## Current Abilities

At present, `framegear` is able to validate the initial frame response against the
[current Frame Specification](https://docs.farcaster.xyz/reference/frames/spec) and interact with frames through
buttons using the `post` action.

`framegear` is under active development and much more functionality is on the roadmap including (but not limited to):

- more button actions
- text input
- simulated conditions
- viewer followed
- viewer liked
- viewer recasted

A partial roadmap can be viewed at https://github.com/coinbase/onchainkit/issues/146
2 changes: 2 additions & 0 deletions site/docs/pages/framekit/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ To assist you in engaging with Frames, here is the Frame Kit which includes:
- [`getFrameHtmlResponse`](/framekit/get-frame-html-response)
- [`getFrameMessage`](/framekit/get-frame-message)
- [`getFrameMetadata`](/framekit/get-frame-metadata)
- Emulator
- [`framegear`](/framekit/framegear)
2 changes: 2 additions & 0 deletions site/docs/pages/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ OnchainKit is divided into various theme utilities and components that are avail
- [`getFrameHtmlResponse`](/framekit/get-frame-html-response)
- [`getFrameMessage`](/framekit/get-frame-message)
- [`getFrameMetadata`](/framekit/get-frame-metadata)
- Emulator
- [`framegear`](/framekit/framegear)

- [Identity Kit](/identitykit/introduction)
- Components:
Expand Down
2 changes: 2 additions & 0 deletions site/docs/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ OnchainKit offers three themes packed with React components and TypeScript utili
- [`getFrameHtmlResponse`](/framekit/get-frame-html-response)
- [`getFrameMessage`](/framekit/get-frame-message)
- [`getFrameMetadata`](/framekit/get-frame-metadata)
- Emulator
- [`framegear`](/framekit/framegear)

- [Identity Kit](/identitykit/introduction)
- Components:
Expand Down
4 changes: 4 additions & 0 deletions site/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export const sidebar = [
text: 'Types',
link: '/framekit/types',
},
{
text: 'Emulator',
link: '/framekit/framegear',
},
],
},
{
Expand Down
Loading