diff --git a/.changeset/calm-carpets-divide.md b/.changeset/calm-carpets-divide.md new file mode 100644 index 000000000..c4d4dd979 --- /dev/null +++ b/.changeset/calm-carpets-divide.md @@ -0,0 +1,5 @@ +--- +"@frames.js/render": minor +--- + +feat: rename types diff --git a/.changeset/chilly-parrots-sneeze.md b/.changeset/chilly-parrots-sneeze.md new file mode 100644 index 000000000..0db32b7d5 --- /dev/null +++ b/.changeset/chilly-parrots-sneeze.md @@ -0,0 +1,6 @@ +--- +"frames.js": patch +"@frames.js/debugger": patch +--- + +fix: minor bugs and code cleanup diff --git a/.eslintrc.js b/.eslintrc.js index 9e9676e90..22f3ef9bd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,11 +2,8 @@ /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["@framesjs/eslint-config/library.js"], - parser: "@typescript-eslint/parser", parserOptions: { project: true, }, - env: { - jest: true, - }, + ignorePatterns: ["**/farcaster/generated/*.ts"], }; diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 0864acc1f..64e14b140 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -50,6 +50,9 @@ jobs: - name: Install dependencies run: yarn --frozen-lockfile + - name: Build + run: yarn build:ci + - name: Lint run: yarn lint diff --git a/packages/debugger/app/components/frame-debugger.tsx b/packages/debugger/app/components/frame-debugger.tsx index 0027899fd..2cfb9cb73 100644 --- a/packages/debugger/app/components/frame-debugger.tsx +++ b/packages/debugger/app/components/frame-debugger.tsx @@ -1,7 +1,7 @@ import { getFrameHtmlHead, getFrameFlattened } from "frames.js"; import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import React from "react"; -import { FrameState, FrameRequest, FrameStackSuccess } from "frames.js/render"; +import { FrameState, FrameStackSuccess } from "@frames.js/render"; import { Table, TableBody, TableCell, TableRow } from "@/components/table"; import { AlertTriangle, @@ -10,7 +10,6 @@ import { HomeIcon, ListIcon, LoaderIcon, - MessageCircle, MessageCircleHeart, RefreshCwIcon, XCircle, @@ -190,20 +189,22 @@ export function FrameDebugger({ const [openAccordions, setOpenAccordions] = useState([]); + const [latestFrame] = frameState.framesStack; + useEffect(() => { if (!frameState.isLoading) { // make sure the first frame is open if ( !openAccordions.includes( - String(frameState.framesStack[0]?.timestamp.getTime()) + String(latestFrame?.timestamp.getTime()) ) ) setOpenAccordions((v) => [ ...v, - String(frameState.framesStack[0]?.timestamp.getTime()), + String(latestFrame?.timestamp.getTime()), ]); } - }, [frameState.isLoading]); + }, [frameState.isLoading, latestFrame?.timestamp, openAccordions]); return (
@@ -219,7 +220,6 @@ export function FrameDebugger({ frameState.fetchFrame({ url: frameState?.homeframeUrl, method: "GET", - request: {}, }); }} > @@ -235,7 +235,6 @@ export function FrameDebugger({ frameState.fetchFrame({ url: frameState?.homeframeUrl, method: "GET", - request: {}, }); } }} @@ -246,12 +245,21 @@ export function FrameDebugger({ className="flex flex-row gap-3 items-center shadow-sm border" variant={"outline"} onClick={() => { - if (frameState?.framesStack[0]?.request) { - frameState.fetchFrame({ - url: frameState?.framesStack[0].url, - method: frameState?.framesStack[0].method, - request: frameState.framesStack[0].request, - } as FrameRequest); + const [latestFrame] = frameState.framesStack; + + if (latestFrame) { + frameState.fetchFrame( + latestFrame.method === "GET" + ? { + method: "GET", + url: latestFrame.url, + } + : { + method: "POST", + request: latestFrame.request, + url: latestFrame.url, + } + ); } }} > @@ -269,11 +277,14 @@ export function FrameDebugger({ className={`px-4 py-3 flex flex-col gap-2 ${i !== 0 ? "border-t" : "bg-slate-50"} hover:bg-slate-50 w-full`} key={frameStackItem.timestamp.getTime()} onClick={() => { - frameState.fetchFrame({ + frameState.fetchFrame(frameStackItem.method === 'GET' ? { + method: 'GET', + url: frameStackItem.url, + } : { url: frameStackItem.url, method: frameStackItem.method, request: frameStackItem.request, - } as FrameRequest); + }); }} > diff --git a/packages/debugger/app/page.tsx b/packages/debugger/app/page.tsx index d172c9dde..4c2b8831a 100644 --- a/packages/debugger/app/page.tsx +++ b/packages/debugger/app/page.tsx @@ -60,7 +60,7 @@ export default function App({ }, []); useEffect(() => { - if (url !== urlInput && url) { + if (url) { setUrlInput(url); } }, [url]); diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index c667cd100..273db5ba6 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -4,15 +4,12 @@ const project = resolve(process.cwd(), "tsconfig.json"); /** @type {import("eslint").Linter.Config} */ module.exports = { - extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], - plugins: ["only-warn"], - globals: { - React: true, - JSX: true, - }, - env: { - node: true, - }, + extends: [ + require.resolve("@vercel/style-guide/eslint/node"), + require.resolve("@vercel/style-guide/eslint/jest-react"), + require.resolve("@vercel/style-guide/eslint/react"), + require.resolve("@vercel/style-guide/eslint/typescript"), + ], settings: { "import/resolver": { typescript: { @@ -31,4 +28,17 @@ module.exports = { files: ["*.js?(x)", "*.ts?(x)"], }, ], + rules: { + "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/require-await": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unnecessary-condition": "warn", + "@typescript-eslint/consistent-type-imports": "error", + "jest/expect-expect": "warn", + "react/jsx-sort-props": "off", + "unicorn/filename-case": "off", + eqeqeq: "off", + "no-await-in-loop": "off", + "no-implicit-coercion": "off", + }, }; diff --git a/packages/frames.js/package.json b/packages/frames.js/package.json index 50d7e30f7..0baef3560 100644 --- a/packages/frames.js/package.json +++ b/packages/frames.js/package.json @@ -250,6 +250,7 @@ "@types/express": "^4.17.21", "@xmtp/frames-client": "^0.4.3", "@xmtp/frames-validator": "^0.5.2", + "@xmtp/proto": "3.45.0", "@xmtp/xmtp-js": "^11.5.0", "express": "^4.19.1", "hono": "^4.1.3", @@ -262,6 +263,7 @@ "@cloudflare/workers-types": "^4.20240320.1", "@types/express": "^4.17.21", "@xmtp/frames-validator": "^0.5.2", + "@xmtp/proto": "3.45.0", "next": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/frames.js/src/cloudflare-workers/index.test.tsx b/packages/frames.js/src/cloudflare-workers/index.test.tsx index 053a6fc4e..737c852b5 100644 --- a/packages/frames.js/src/cloudflare-workers/index.test.tsx +++ b/packages/frames.js/src/cloudflare-workers/index.test.tsx @@ -7,12 +7,12 @@ describe("cloudflare workers adapter", () => { it("correctly integrates with Cloudflare Workers", async () => { const frames = lib.createFrames(); - const handler = frames(async (ctx) => { + const handler = frames((ctx) => { expect(ctx.request.url).toBe("http://localhost:3000/"); return { image: Test, - buttons: [Click me], + buttons: [Click me], }; }); @@ -35,7 +35,7 @@ describe("cloudflare workers adapter", () => { }, }); - const handler = frames(async (ctx) => { + const handler = frames((ctx) => { expect(ctx.state).toEqual({ test: false }); return { diff --git a/packages/frames.js/src/cloudflare-workers/index.ts b/packages/frames.js/src/cloudflare-workers/index.ts index f2fc9b7b8..7fd0270a8 100644 --- a/packages/frames.js/src/cloudflare-workers/index.ts +++ b/packages/frames.js/src/cloudflare-workers/index.ts @@ -1,22 +1,23 @@ -export { Button, type types } from "../core"; -import { createFrames as coreCreateFrames, types } from "../core"; -import type { CoreMiddleware } from "../middleware"; import { Buffer } from "node:buffer"; -import { - type CloudflareWorkersMiddleware, - cloudflareWorkersMiddleware, -} from "./middleware"; import type { ExportedHandlerFetchHandler } from "@cloudflare/workers-types"; +import type { types } from "../core"; +import { createFrames as coreCreateFrames } from "../core"; import type { FramesMiddleware, FramesRequestHandlerFunction, JsonValue, } from "../core/types"; +import type { CoreMiddleware } from "../middleware"; +import { + type CloudflareWorkersMiddleware, + cloudflareWorkersMiddleware, +} from "./middleware"; + +export { Button, type types } from "../core"; export { cloudflareWorkersMiddleware } from "./middleware"; // make Buffer available on globalThis so it is compatible with cloudflare workers -// eslint-disable-next-line no-undef globalThis.Buffer = Buffer; type DefaultMiddleware = [ @@ -28,6 +29,7 @@ type DefaultMiddleware = [ * Creates Frames instance to use with you Hono server * * @example + * ```ts * import { createFrames, Button } from 'frames.js/cloudflare-workers'; * * const frames = createFrames(); @@ -41,8 +43,10 @@ type DefaultMiddleware = [ * ], * }; * }); + * ``` * * @example + * ```ts * // With custom type for Env and state * import { createFrames, Button, type types } from 'frames.js/cloudflare-workers'; * @@ -67,6 +71,7 @@ type DefaultMiddleware = [ * export default { * fetch, * } satisfies ExportedHandler; + * ``` */ export function createFrames< TState extends JsonValue | undefined = JsonValue | undefined, @@ -80,7 +85,7 @@ export function createFrames< TState, DefaultMiddleware, TFramesMiddleware, - ExportedHandlerFetchHandler + ExportedHandlerFetchHandler > { return function cloudflareWorkersFramesHandler< TPerRouteMiddleware extends @@ -107,7 +112,7 @@ export function createFrames< return framesHandler( // @ts-expect-error - req is almost compatible, there are some differences in the types but it mostly fits all the needs req - ) as unknown as ReturnType>; + ) as unknown as ReturnType; }; }; } diff --git a/packages/frames.js/src/cloudflare-workers/middleware.ts b/packages/frames.js/src/cloudflare-workers/middleware.ts index fd323f265..73905a181 100644 --- a/packages/frames.js/src/cloudflare-workers/middleware.ts +++ b/packages/frames.js/src/cloudflare-workers/middleware.ts @@ -3,7 +3,7 @@ import type { IncomingRequestCfProperties, Request, } from "@cloudflare/workers-types"; -import type { FramesMiddleware } from "../core/types"; +import type { FramesMiddleware, JsonValue } from "../core/types"; type CloudflareWorkersMiddlewareContext = { /** @@ -34,7 +34,7 @@ type CloudflareWorkersMiddlewareOptions = { }; export type CloudflareWorkersMiddleware = FramesMiddleware< - any, + JsonValue | undefined, CloudflareWorkersMiddlewareContext >; diff --git a/packages/frames.js/src/cloudflare-workers/test.types.tsx b/packages/frames.js/src/cloudflare-workers/test.types.tsx index 1da5822f3..59f1107f8 100644 --- a/packages/frames.js/src/cloudflare-workers/test.types.tsx +++ b/packages/frames.js/src/cloudflare-workers/test.types.tsx @@ -1,5 +1,7 @@ -import { ExecutionContext, Request as CfRequest, ExportedHandlerFetchHandler } from '@cloudflare/workers-types'; -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we want to test that handler supports async*/ +import type { ExecutionContext, Request as CfRequest, ExportedHandlerFetchHandler } from '@cloudflare/workers-types'; +import type { types } from '.'; +import { createFrames } from '.'; const framesWithoutState = createFrames(); framesWithoutState(async (ctx) => { @@ -26,7 +28,7 @@ framesWithInferredState(async (ctx) => { const framesWithExplicitState = createFrames<{ test: boolean }>({}); framesWithExplicitState(async (ctx) => { ctx.state satisfies { test: boolean }; - ctx satisfies { initialState?: {test: boolean}; message?: any, pressedButton?: any }; + ctx satisfies { initialState?: {test: boolean}; message?: unknown, pressedButton?: unknown }; ctx satisfies { cf: { env: unknown; ctx: ExecutionContext; req: CfRequest }} return { @@ -37,7 +39,18 @@ framesWithExplicitState(async (ctx) => { const framesWithExplicitStateAndEnv = createFrames<{ test: boolean }, { secret: string }>({}); framesWithExplicitStateAndEnv(async (ctx) => { ctx.state satisfies { test: boolean }; - ctx satisfies { initialState?: { test: boolean }; message?: any, pressedButton?: any; request: Request; }; + ctx satisfies { initialState?: { test: boolean }; message?: unknown, pressedButton?: unknown; request: Request; }; + ctx satisfies { cf: { env: { secret: string }; ctx: ExecutionContext; req: CfRequest }} + + return { + image: 'http://test.png', + }; +}) satisfies ExportedHandlerFetchHandler<{ secret: string }>; + +const framesWithExplicitStateAndEnvNoPromiseHandler = createFrames<{ test: boolean }, { secret: string }>({}); +framesWithExplicitStateAndEnvNoPromiseHandler((ctx) => { + ctx.state satisfies { test: boolean }; + ctx satisfies { initialState?: { test: boolean }; message?: unknown, pressedButton?: unknown; request: Request; }; ctx satisfies { cf: { env: { secret: string }; ctx: ExecutionContext; req: CfRequest }} return { diff --git a/packages/frames.js/src/core/components.ts b/packages/frames.js/src/core/components.ts index 2b2e380e2..abdb940bf 100644 --- a/packages/frames.js/src/core/components.ts +++ b/packages/frames.js/src/core/components.ts @@ -1,4 +1,4 @@ -import type { UrlObject } from "url"; +import type { UrlObject } from "node:url"; type PostButtonProps = { /** A 256-byte string which is label of the button */ @@ -63,6 +63,6 @@ export type ButtonProps = | LinkButtonProps | TxButtonProps; -export const Button: React.FunctionComponent = () => { +export function Button(_: ButtonProps): null { return null; -}; +} diff --git a/packages/frames.js/src/core/composeMiddleware.test.ts b/packages/frames.js/src/core/composeMiddleware.test.ts index e437b427b..9f4a8e169 100644 --- a/packages/frames.js/src/core/composeMiddleware.test.ts +++ b/packages/frames.js/src/core/composeMiddleware.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- we expect promises */ import { composeMiddleware } from "./composeMiddleware"; describe("composeMiddleware", () => { @@ -10,7 +11,7 @@ describe("composeMiddleware", () => { it("properly works with just one middleware", async () => { const context = { a: 1 }; - const composedMiddleware = composeMiddleware([ + const composedMiddleware = composeMiddleware([ async (ctx, next) => { ctx.a = 2; return next(); @@ -31,7 +32,10 @@ describe("composeMiddleware", () => { order: [], }; - const composedMiddleware = composeMiddleware>([ + const composedMiddleware = composeMiddleware< + typeof context, + Promise | string + >([ async (ctx: typeof context, next) => { ctx.order.push(1); const result = await next({ @@ -54,7 +58,7 @@ describe("composeMiddleware", () => { return result; }, - async (ctx: typeof context) => { + (ctx: typeof context) => { ctx.order.push(3); expect(ctx).toMatchObject({ 1: true, 2: "test" }); diff --git a/packages/frames.js/src/core/composeMiddleware.ts b/packages/frames.js/src/core/composeMiddleware.ts index 3e5ac2fea..665f78320 100644 --- a/packages/frames.js/src/core/composeMiddleware.ts +++ b/packages/frames.js/src/core/composeMiddleware.ts @@ -25,6 +25,8 @@ export function composeMiddleware( ); return (ctx) => { - return composedMiddleware(ctx, (() => {}) as any); + return composedMiddleware(ctx, (() => { + return undefined; + }) as () => TReturnType); }; } diff --git a/packages/frames.js/src/core/createFrames.test.ts b/packages/frames.js/src/core/createFrames.test.ts index 90b7ae3f5..c03ae3ae3 100644 --- a/packages/frames.js/src/core/createFrames.test.ts +++ b/packages/frames.js/src/core/createFrames.test.ts @@ -1,7 +1,7 @@ -import { createFrames } from "./createFrames"; import { concurrentMiddleware } from "../middleware/concurrentMiddleware"; +import { createFrames } from "./createFrames"; import { redirect } from "./redirect"; -import { FramesMiddleware } from "./types"; +import type { FramesMiddleware } from "./types"; describe("createFrames", () => { it("returns a handler function", () => { @@ -13,7 +13,7 @@ describe("createFrames", () => { it("provides default properties on context based on default middleware and internal logic", async () => { const handler = createFrames(); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { expect(ctx.url).toBeInstanceOf(URL); expect(ctx.url.href).toBe("http://test.com/"); @@ -32,7 +32,7 @@ describe("createFrames", () => { it("passes initialState to context", async () => { const handler = createFrames({ initialState: { test: true } }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { expect(ctx.initialState).toEqual({ test: true }); return redirect("http://test.com"); }); @@ -54,7 +54,7 @@ describe("createFrames", () => { middleware: [customMiddleware], }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { return redirect(ctx.custom); }); @@ -75,7 +75,7 @@ describe("createFrames", () => { const handler = createFrames(); const routeHandler = handler( - async (ctx) => { + (ctx) => { return redirect(ctx.custom); }, { @@ -90,16 +90,28 @@ describe("createFrames", () => { }); it("works with parallel middleware", async () => { - const middleware0 = async (context: any, next: any) => { + const middleware0: FramesMiddleware = async ( + context, + next + ) => { return next({ test0: true }); }; - const middleware1 = async (context: any, next: any) => { + const middleware1: FramesMiddleware = async ( + context, + next + ) => { return next({ test1: true }); }; - const middleware2 = async (context: any, next: any) => { + const middleware2: FramesMiddleware = async ( + context, + next + ) => { return next({ test2: true }); }; - const middleware3 = async (context: any, next: any) => { + const middleware3: FramesMiddleware = async ( + context, + next + ) => { return next({ test3: true }); }; @@ -111,7 +123,7 @@ describe("createFrames", () => { ], }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { expect(ctx).toMatchObject({ test0: true, test1: true, diff --git a/packages/frames.js/src/core/createFrames.ts b/packages/frames.js/src/core/createFrames.ts index 4fd9a0524..8cf98fb6c 100644 --- a/packages/frames.js/src/core/createFrames.ts +++ b/packages/frames.js/src/core/createFrames.ts @@ -1,4 +1,4 @@ -import { coreMiddleware } from "../middleware"; +import { coreMiddleware } from "../middleware/default"; import { stateMiddleware } from "../middleware/stateMiddleware"; import { composeMiddleware } from "./composeMiddleware"; import type { @@ -21,7 +21,7 @@ export function createFrames< TState, typeof coreMiddleware, TMiddlewares, - (req: Request) => Promise + (req: Request) => Promise | Response > { const globalMiddleware: FramesMiddleware>[] = middleware || []; @@ -30,8 +30,10 @@ export function createFrames< * This function takes handler function that does the logic with the help of context and returns one of possible results */ return function createFramesRequestHandler(handler, options = {}) { - const perRouteMiddleware: FramesMiddleware>[] = - options && Array.isArray(options.middleware) ? options.middleware : []; + const perRouteMiddleware: FramesMiddleware< + JsonValue | undefined, + FramesContext + >[] = Array.isArray(options.middleware) ? options.middleware : []; const composedMiddleware = composeMiddleware< FramesContext, diff --git a/packages/frames.js/src/core/formatUrl.ts b/packages/frames.js/src/core/formatUrl.ts index a417eb9af..ed427a82d 100644 --- a/packages/frames.js/src/core/formatUrl.ts +++ b/packages/frames.js/src/core/formatUrl.ts @@ -20,8 +20,8 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -import type { UrlObject } from "url"; -import type { ParsedUrlQuery } from "querystring"; +import type { UrlObject } from "node:url"; +import type { ParsedUrlQuery } from "node:querystring"; // function searchParamsToUrlQuery(searchParams: URLSearchParams): ParsedUrlQuery { // const query: ParsedUrlQuery = {}; @@ -44,16 +44,18 @@ function stringifyUrlQueryParam(param: unknown): string { typeof param === "boolean" ) { return String(param); - } else { - return ""; } + + return ""; } function urlQueryToSearchParams(urlQuery: ParsedUrlQuery): URLSearchParams { const result = new URLSearchParams(); Object.entries(urlQuery).forEach(([key, value]) => { if (Array.isArray(value)) { - value.forEach((item) => result.append(key, stringifyUrlQueryParam(item))); + value.forEach((item) => { + result.append(key, stringifyUrlQueryParam(item)); + }); } else { result.set(key, stringifyUrlQueryParam(value)); } @@ -74,22 +76,23 @@ function urlQueryToSearchParams(urlQuery: ParsedUrlQuery): URLSearchParams { const slashedProtocols = /https?|ftp|gopher|file/; -export function formatUrl(urlObj: UrlObject) { - let { auth, hostname } = urlObj; +export function formatUrl(urlObj: UrlObject): string { + let { auth } = urlObj; + const { hostname } = urlObj; let protocol = urlObj.protocol || ""; let pathname = urlObj.pathname || ""; let hash = urlObj.hash || ""; let query = urlObj.query || ""; let host: string | false = false; - auth = auth ? encodeURIComponent(auth).replace(/%3A/i, ":") + "@" : ""; + auth = auth ? `${encodeURIComponent(auth).replace(/%3A/i, ":")}@` : ""; if (urlObj.host) { host = auth + urlObj.host; } else if (hostname) { - host = auth + (~hostname.indexOf(":") ? `[${hostname}]` : hostname); + host = auth + (hostname.includes(":") ? `[${hostname}]` : hostname); if (urlObj.port) { - host += ":" + urlObj.port; + host += `:${urlObj.port}`; } } @@ -105,14 +108,16 @@ export function formatUrl(urlObj: UrlObject) { urlObj.slashes || ((!protocol || slashedProtocols.test(protocol)) && host !== false) ) { - host = "//" + (host || ""); - if (pathname && pathname[0] !== "/") pathname = "/" + pathname; + host = `//${host || ""}`; + if (pathname && !pathname.startsWith("/")) { + pathname = `/${pathname}`; + } } else if (!host) { host = ""; } - if (hash && hash[0] !== "#") hash = "#" + hash; - if (search && search[0] !== "?") search = "?" + search; + if (hash && !hash.startsWith("#")) hash = `#${hash}`; + if (search && !search.startsWith("?")) search = `?${search}`; pathname = pathname.replace(/[?#]/g, encodeURIComponent); search = search.replace("#", "%23"); diff --git a/packages/frames.js/src/core/redirect.ts b/packages/frames.js/src/core/redirect.ts index 0eeb079ca..f3031220e 100644 --- a/packages/frames.js/src/core/redirect.ts +++ b/packages/frames.js/src/core/redirect.ts @@ -1,4 +1,4 @@ -import { FrameRedirect } from "./types"; +import type { FrameRedirect } from "./types"; /** * Creates a redirect with 302 as default status diff --git a/packages/frames.js/src/core/test.types.tsx b/packages/frames.js/src/core/test.types.tsx index 52580336c..28e333a49 100644 --- a/packages/frames.js/src/core/test.types.tsx +++ b/packages/frames.js/src/core/test.types.tsx @@ -1,6 +1,8 @@ -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we are checking compatibility with promises */ +import type { types } from '.'; +import { createFrames } from '.'; -type Handler = (req: Request) => Promise; +type Handler = (req: Request) => Promise | Response; const framesWithoutState = createFrames(); framesWithoutState(async (ctx) => { @@ -27,7 +29,7 @@ framesWithInferredState(async (ctx) => { const framesWithExplicitState = createFrames<{ test: boolean }>({}); framesWithExplicitState(async (ctx) => { ctx.state satisfies { test: boolean }; - ctx satisfies { initialState?: {test: boolean}; message?: any, pressedButton?: any }; + ctx satisfies { initialState?: {test: boolean}; message?: unknown, pressedButton?: unknown }; return { image: 'http://test.png', @@ -37,7 +39,18 @@ framesWithExplicitState(async (ctx) => { const framesWithExplicitStateAndEnv = createFrames<{ test: boolean }>({}); framesWithExplicitStateAndEnv(async (ctx) => { ctx.state satisfies { test: boolean }; - ctx satisfies { initialState?: { test: boolean }; message?: any, pressedButton?: any; request: Request; }; + ctx satisfies { initialState?: { test: boolean }; message?: unknown, pressedButton?: unknown; request: Request; }; + + + return { + image: 'http://test.png', + }; +}) satisfies Handler; + +const framesWithExplicitStateAndEnvNoPromise = createFrames<{ test: boolean }>({}); +framesWithExplicitStateAndEnvNoPromise((ctx) => { + ctx.state satisfies { test: boolean }; + ctx satisfies { initialState?: { test: boolean }; message?: unknown, pressedButton?: unknown; request: Request; }; return { diff --git a/packages/frames.js/src/core/types.ts b/packages/frames.js/src/core/types.ts index a3ed3f1bd..9d4b22e3f 100644 --- a/packages/frames.js/src/core/types.ts +++ b/packages/frames.js/src/core/types.ts @@ -1,6 +1,6 @@ -import { ImageResponse } from "@vercel/og"; +import type { ImageResponse } from "@vercel/og"; import type { ClientProtocolId } from "../types"; -import { Button } from "./components"; +import type { Button } from "./components"; export type JsonObject = { [Key in string]: JsonValue } & { [Key in string]?: JsonValue | undefined; @@ -67,7 +67,7 @@ export type FrameDefinition = { image: React.ReactElement | string; imageOptions?: { /** - * @default '1.91:1' + * @defaultValue '1.91:1' */ aspectRatio?: "1.91:1" | "1:1"; } & ConstructorParameters[1]; @@ -134,12 +134,14 @@ export type FrameHandlerFunction< > = ( // we pass ctx.state here since it is made available internally by stateMiddleware but the inference would not work ctx: FramesContext & TFramesContext & { state: TState } -) => Promise>; +) => + | Promise> + | FramesHandlerFunctionReturnType; export type FramesContextFromMiddlewares< TMiddlewares extends | FramesMiddleware[] - | ReadonlyArray>, + | readonly FramesMiddleware[], > = UnionToIntersection< { [K in keyof TMiddlewares]: TMiddlewares[K] extends FramesMiddleware< @@ -160,11 +162,11 @@ export type FramesRequestHandlerFunctionOptions< export type FramesRequestHandlerFunction< TState extends JsonValue | undefined, TDefaultMiddleware extends - | ReadonlyArray> + | readonly FramesMiddleware[] | FramesMiddleware[] | undefined, TFrameMiddleware extends FramesMiddleware[] | undefined, - TRequestHandlerFunction extends Function, + TRequestHandlerFunction extends (...args: any[]) => any, > = < TPerRouteMiddleware extends | FramesMiddleware[] @@ -173,13 +175,13 @@ export type FramesRequestHandlerFunction< handler: FrameHandlerFunction< TState, (TDefaultMiddleware extends undefined - ? {} + ? Record : FramesContextFromMiddlewares>) & (TFrameMiddleware extends undefined - ? {} + ? Record : FramesContextFromMiddlewares>) & (TPerRouteMiddleware extends undefined - ? {} + ? Record : FramesContextFromMiddlewares>) >, options?: FramesRequestHandlerFunctionOptions @@ -191,7 +193,7 @@ export type FramesOptions< > = { /** * All frame relative targets will be resolved relative to this - * @default '/'' + * @defaultValue '/' */ basePath?: string; /** @@ -207,10 +209,10 @@ export type FramesOptions< export type CreateFramesFunctionDefinition< TDefaultMiddleware extends - | ReadonlyArray> + | readonly FramesMiddleware[] | FramesMiddleware[] | undefined, - TRequestHandlerFunction extends Function, + TRequestHandlerFunction extends (...args: any[]) => any, > = < TState extends JsonValue | undefined = JsonValue | undefined, TFrameMiddleware extends FramesMiddleware[] | undefined = undefined, diff --git a/packages/frames.js/src/core/utils.ts b/packages/frames.js/src/core/utils.ts index c29adae7b..176d7bbce 100644 --- a/packages/frames.js/src/core/utils.ts +++ b/packages/frames.js/src/core/utils.ts @@ -1,6 +1,6 @@ +import type { UrlObject } from "node:url"; import { formatUrl } from "./formatUrl"; -import { FrameDefinition, FrameRedirect } from "./types"; -import type { UrlObject } from "url"; +import type { FrameDefinition, FrameRedirect, JsonValue } from "./types"; const buttonActionToCode = { post: "p", @@ -9,7 +9,7 @@ const buttonActionToCode = { const BUTTON_INFORMATION_SEARCH_PARAM_NAME = "__bi"; -function isValidButtonIndex(index: any): index is 1 | 2 | 3 | 4 { +function isValidButtonIndex(index: unknown): index is 1 | 2 | 3 | 4 { return ( typeof index === "number" && !Number.isNaN(index) && @@ -18,7 +18,9 @@ function isValidButtonIndex(index: any): index is 1 | 2 | 3 | 4 { ); } -function isValidButtonAction(action: any): action is "post" | "post_redirect" { +function isValidButtonAction( + action: unknown +): action is "post" | "post_redirect" { return ( typeof action === "string" && (action === "post" || action === "post_redirect") @@ -38,7 +40,7 @@ export function generateTargetURL({ if ( target && - typeof target == "string" && + typeof target === "string" && (target.startsWith("http://") || target.startsWith("https://")) ) { // handle absolute urls @@ -46,7 +48,7 @@ export function generateTargetURL({ } else if (target && typeof target === "string") { // resolve target relatively to basePath const baseUrl = new URL(basePath, currentURL); - const preformatted = baseUrl.pathname + "/" + target; + const preformatted = `${baseUrl.pathname}/${target}`; const parts = preformatted.split("/").filter(Boolean); const finalPathname = parts.join("/"); @@ -67,13 +69,11 @@ export function generateTargetURL({ port: url.port, // query: url.searchParams, ...target, - pathname: - "/" + - [basePath ?? "", "/", target.pathname ?? ""] - .join("") - .split("/") - .filter(Boolean) - .join("/"), + pathname: `/${[basePath, "/", target.pathname ?? ""] + .join("") + .split("/") + .filter(Boolean) + .join("/")}`, }) ); } @@ -115,9 +115,7 @@ type ButtonInformation = { }; export function parseSearchParams(url: URL): { - searchParams: { - [k: string]: string; - }; + searchParams: Record; } { const searchParams = Object.fromEntries(url.searchParams); @@ -159,10 +157,17 @@ export function parseButtonInformationFromTargetURL( }; } -export function isFrameRedirect(value: any): value is FrameRedirect { - return value && typeof value === "object" && value.kind === "redirect"; +export function isFrameRedirect(value: unknown): value is FrameRedirect { + return ( + value !== null && + typeof value === "object" && + "kind" in value && + value.kind === "redirect" + ); } -export function isFrameDefinition(value: any): value is FrameDefinition { - return value && typeof value === "object" && "image" in value; +export function isFrameDefinition( + value: unknown +): value is FrameDefinition { + return value !== null && typeof value === "object" && "image" in value; } diff --git a/packages/frames.js/src/express/index.test.tsx b/packages/frames.js/src/express/index.test.tsx index 0c2051125..680c4e0ee 100644 --- a/packages/frames.js/src/express/index.test.tsx +++ b/packages/frames.js/src/express/index.test.tsx @@ -1,7 +1,7 @@ import express from "express"; import request from "supertest"; -import * as lib from "."; import { FRAMES_META_TAGS_HEADER } from "../core"; +import * as lib from "."; describe("express adapter", () => { it.each(["createFrames", "Button"])("exports %s", (exportName) => { @@ -11,9 +11,9 @@ describe("express adapter", () => { it("properly correctly integrates with express.js app", async () => { const app = express(); const frames = lib.createFrames(); - const expressHandler = frames(async ({ request }) => { - expect(request).toBeInstanceOf(Request); - expect(request.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); + const expressHandler = frames(({ request: req }) => { + expect(req).toBeInstanceOf(Request); + expect(req.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); return { image: Nehehe, @@ -33,9 +33,9 @@ describe("express adapter", () => { it("properly correctly integrates with express.js app and returns JSON if asked to", async () => { const app = express(); const frames = lib.createFrames(); - const expressHandler = frames(async ({ request }) => { - expect(request).toBeInstanceOf(Request); - expect(request.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); + const expressHandler = frames(({ request: req }) => { + expect(req).toBeInstanceOf(Request); + expect(req.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); return { image: Nehehe, @@ -59,7 +59,7 @@ describe("express adapter", () => { it("properly handles error response", async () => { const app = express(); const frames = lib.createFrames(); - const expressHandler = frames(async () => { + const expressHandler = frames(() => { throw new Error("Something went wrong"); }); @@ -74,9 +74,9 @@ describe("express adapter", () => { it("resolves button targets correctly", async () => { const app = express(); const frames = lib.createFrames({ basePath: "/api" }); - const expressHandler = frames(async ({ request }) => { - expect(request).toBeInstanceOf(Request); - expect(request.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); + const expressHandler = frames(({ request: req }) => { + expect(req).toBeInstanceOf(Request); + expect(req.url).toMatch(/http:\/\/127\.0\.0\.1:\d+\//); return { image: Nehehe, @@ -96,7 +96,7 @@ describe("express adapter", () => { .expect(200) .expect("Content-type", "application/json") .expect((res) => { - expect(res.body["fc:frame:button:1:target"]).toMatch( + expect((res.body as Record)["fc:frame:button:1:target"]).toMatch( /http:\/\/127\.0\.0\.1:\d+\/api\/nested/ ); }); @@ -113,7 +113,7 @@ describe("express adapter", () => { }, }); - const expressHandler = frames(async (ctx) => { + const expressHandler = frames((ctx) => { expect(ctx.state).toEqual({ test: false }); return { diff --git a/packages/frames.js/src/express/index.ts b/packages/frames.js/src/express/index.ts index 7cfbebb96..7f9fbb976 100644 --- a/packages/frames.js/src/express/index.ts +++ b/packages/frames.js/src/express/index.ts @@ -1,15 +1,17 @@ +import type { IncomingHttpHeaders } from "node:http"; import type { Handler as ExpressHandler, Request as ExpressRequest, Response as ExpressResponse, } from "express"; -import type { IncomingHttpHeaders } from "node:http"; -import { createFrames as coreCreateFrames, types } from "../core"; +import type { types } from "../core"; +import { createFrames as coreCreateFrames } from "../core"; import { createReadableStreamFromReadable, writeReadableStreamToWritable, } from "../lib/stream-pump"; -import { CoreMiddleware } from "../middleware"; +import type { CoreMiddleware } from "../middleware"; + export { Button, type types } from "../core"; type CreateFramesForExpress = types.CreateFramesFunctionDefinition< @@ -21,6 +23,7 @@ type CreateFramesForExpress = types.CreateFramesFunctionDefinition< * Creates Frames instance to use with you Express.js server * * @example + * ```tsx * import { createFrames, Button } from 'frames.js/express'; * import express from 'express'; * @@ -37,8 +40,9 @@ type CreateFramesForExpress = types.CreateFramesFunctionDefinition< * }; * * app.use("/", expressHandler); + * ``` */ -// @ts-expect-error +// @ts-expect-error -- this code is correct just function doesn't satisfy the type export const createFrames: CreateFramesForExpress = function createFramesForExpress(options?: types.FramesOptions) { const frames = coreCreateFrames(options); @@ -51,15 +55,21 @@ export const createFrames: CreateFramesForExpress = ) { const framesHandler = frames(handler, handlerOptions); - return async function handleExpressRequest( + return function handleExpressRequest( req: ExpressRequest, res: ExpressResponse ) { // convert express.js req to Web API Request - - const response = await framesHandler(createRequest(req, res)); - - sendResponse(res, response); + const response = framesHandler(createRequest(req, res)); + + Promise.resolve(response) + .then((resolvedResponse) => sendResponse(res, resolvedResponse)) + .catch((error) => { + // eslint-disable-next-line no-console -- provide feedback + console.error(error); + res.writeHead(500, { "content-type": "text/plain" }); + res.end("Inernal server error"); + }); }; }; }; @@ -69,17 +79,19 @@ function createRequest(req: ExpressRequest, res: ExpressResponse): Request { // `X-Forwarded-Host` or `Host` const [, hostnamePort] = req.get("X-Forwarded-Host")?.split(":") ?? []; const [, hostPort] = req.get("Host")?.split(":") ?? []; - let port = hostnamePort || hostPort; + const port = hostnamePort || hostPort; // Use req.hostname here as it respects the "trust proxy" setting - let resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; + const resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; // Use `req.originalUrl` so Remix is aware of the full path - let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); + const url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); // Abort action/loaders once we can no longer write a response - let controller = new AbortController(); - res.on("close", () => controller.abort()); + const controller = new AbortController(); + res.on("close", () => { + controller.abort(); + }); - let init: RequestInit = { + const init: RequestInit = { method: req.method, headers: convertIncomingHTTPHeadersToHeaders(req.headers), signal: controller.signal, @@ -111,11 +123,14 @@ function convertIncomingHTTPHeadersToHeaders( return headers; } -async function sendResponse(res: ExpressResponse, response: Response) { +async function sendResponse( + res: ExpressResponse, + response: Response +): Promise { res.statusMessage = response.statusText; res.status(response.status); - for (let [key, value] of response.headers.entries()) { + for (const [key, value] of response.headers.entries()) { res.setHeader(key, value); } diff --git a/packages/frames.js/src/express/test.types.tsx b/packages/frames.js/src/express/test.types.tsx index 1ca690834..55ebd55be 100644 --- a/packages/frames.js/src/express/test.types.tsx +++ b/packages/frames.js/src/express/test.types.tsx @@ -1,5 +1,7 @@ -import { Handler } from 'express'; -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we want to test if the types are compatible */ +import type { Handler } from 'express'; +import type { types } from '.'; +import { createFrames } from '.'; const framesWithoutState = createFrames(); framesWithoutState(async ctx => { @@ -38,8 +40,29 @@ framesWithExplicitState(async ctx => { test: boolean; }; ctx satisfies { - message?: any; - pressedButton?: any; + message?: unknown; + pressedButton?: unknown; + request: Request; + } + + return { + image: 'http://test.png' + }; +}) satisfies Handler; + +const framesWithExplicitStateNoPromise = createFrames<{ + test: boolean; +}>({}); +framesWithExplicitStateNoPromise(ctx => { + ctx.state satisfies { + test: boolean; + }; + ctx.initialState satisfies { + test: boolean; + }; + ctx satisfies { + message?: unknown; + pressedButton?: unknown; request: Request; } diff --git a/packages/frames.js/src/farcaster/types.ts b/packages/frames.js/src/farcaster/types.ts index 234288c15..f2e5e26da 100644 --- a/packages/frames.js/src/farcaster/types.ts +++ b/packages/frames.js/src/farcaster/types.ts @@ -1,5 +1,5 @@ -import * as protobufs from "./generated/message"; -import { UserNameProof } from "./generated/username_proof"; +import type * as protobufs from "./generated/message"; +import type { UserNameProof } from "./generated/username_proof"; /** Message types */ diff --git a/packages/frames.js/src/getAddressForFid.test.ts b/packages/frames.js/src/getAddressForFid.test.ts index 981cd3756..1803114e9 100644 --- a/packages/frames.js/src/getAddressForFid.test.ts +++ b/packages/frames.js/src/getAddressForFid.test.ts @@ -1,10 +1,11 @@ -import nock from "nock"; -import { getAddressForFid } from "."; +// eslint-disable-next-line import/no-extraneous-dependencies -- dev dependency +import nock, { cleanAll } from "nock"; import { DEFAULT_HUB_API_URL } from "./default"; +import { getAddressForFid } from "."; describe("getAddressForFid", () => { beforeEach(() => { - nock.cleanAll(); + cleanAll(); }); it("returns address for fid with connected address", async () => { diff --git a/packages/frames.js/src/getAddressForFid.ts b/packages/frames.js/src/getAddressForFid.ts index ef0b2005d..8360e89e7 100644 --- a/packages/frames.js/src/getAddressForFid.ts +++ b/packages/frames.js/src/getAddressForFid.ts @@ -40,7 +40,7 @@ export async function getAddressForFid< throw new Error( `Failed to parse response body as JSON because server hub returned response with status "${response.status}" and body "${await response.clone().text()}"` ); - })) as { messages?: Record[] }; + })) as { messages?: Record[] }; let address: AddressReturnType | null = null; @@ -55,7 +55,11 @@ export async function getAddressForFid< } } - if (!address && fallbackToCustodyAddress) { + if (address) { + return address; + } + + if (fallbackToCustodyAddress) { const publicClient = createPublicClient({ transport: http(), chain: optimism, @@ -69,5 +73,5 @@ export async function getAddressForFid< }); } - return address as AddressReturnType; + return address as unknown as AddressReturnType; } diff --git a/packages/frames.js/src/getAddressesForFid.test.ts b/packages/frames.js/src/getAddressesForFid.test.ts index 31f71b641..0ffd3470b 100644 --- a/packages/frames.js/src/getAddressesForFid.test.ts +++ b/packages/frames.js/src/getAddressesForFid.test.ts @@ -1,10 +1,11 @@ -import nock from "nock"; +// eslint-disable-next-line import/no-extraneous-dependencies -- dev dependency +import nock, { cleanAll } from "nock"; import { getAddressesForFid } from "./getAddressesForFid"; import { DEFAULT_HUB_API_URL } from "./default"; describe("getAddressesForFid", () => { afterEach(() => { - nock.cleanAll(); + cleanAll(); }); it("returns address for fid with connected address", async () => { diff --git a/packages/frames.js/src/getAddressesForFid.ts b/packages/frames.js/src/getAddressesForFid.ts index a6f5369e1..80918533d 100644 --- a/packages/frames.js/src/getAddressesForFid.ts +++ b/packages/frames.js/src/getAddressesForFid.ts @@ -51,7 +51,7 @@ export async function getAddressesForFid({ throw new Error( `Failed to parse response body as JSON because server hub returned response with status "${verificationsResponse.status}" and body "${await verificationsResponse.clone().text()}"` ); - })) as { messages?: Record[] }; + })) as { messages?: Record[] }; if (messages) { const verifiedAddresses = messages @@ -70,7 +70,7 @@ export async function getAddressesForFid({ // filter out unsupported addresses .filter((val): val is AddressWithType => val !== null); return [...verifiedAddresses, custodyAddress]; - } else { - return [custodyAddress]; } + + return [custodyAddress]; } diff --git a/packages/frames.js/src/getFrame.test.ts b/packages/frames.js/src/getFrame.test.ts index 65e3bd02b..92f4a4f7b 100644 --- a/packages/frames.js/src/getFrame.test.ts +++ b/packages/frames.js/src/getFrame.test.ts @@ -1,6 +1,6 @@ import { getFrame } from "./getFrame"; import { getFrameHtml } from "./getFrameHtml"; -import { Frame } from "./types"; +import type { Frame } from "./types"; describe("getFrame", () => { const sampleHtml = ` @@ -21,25 +21,21 @@ describe("getFrame", () => { { label: "Green", action: "post", - post_url: undefined, target: undefined, }, { label: "Purple", action: "post", - post_url: undefined, target: undefined, }, { label: "Red", action: "post", - post_url: undefined, target: undefined, }, { label: "Blue", action: "post", - post_url: undefined, target: undefined, }, ], @@ -144,7 +140,7 @@ describe("getFrame", () => { url: "https://example.com", }); - expect(frame?.imageAspectRatio).toEqual("1:91"); + expect(frame.imageAspectRatio).toEqual("1:91"); const html2 = ` @@ -158,7 +154,7 @@ describe("getFrame", () => { url: "https://example.com", }); - expect(frame2?.imageAspectRatio).toEqual("1:1"); + expect(frame2.imageAspectRatio).toEqual("1:1"); }); it("should reject invalid aspect ratio", () => { @@ -194,25 +190,21 @@ describe("getFrame", () => { { action: "post", label: "1", - post_url: undefined, target: undefined, }, { action: "post_redirect", label: "2", - post_url: undefined, target: undefined, }, { action: "link", label: "3", - post_url: undefined, target: "https://example.com", }, { action: "mint", label: "Mint", - post_url: undefined, target: "eip155:7777777:0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df", }, ], @@ -244,7 +236,7 @@ describe("getFrame", () => { url: "https://example.com", }); - expect(frame?.accepts).toEqual([ + expect(frame.accepts).toEqual([ { id: "xmtp", version: "vNext" }, { id: "lens", version: "1.5" }, ]); diff --git a/packages/frames.js/src/getFrame.ts b/packages/frames.js/src/getFrame.ts index 6206e00aa..129ea6088 100644 --- a/packages/frames.js/src/getFrame.ts +++ b/packages/frames.js/src/getFrame.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console -- provide feedback */ import * as cheerio from "cheerio"; import { getByteLength, @@ -19,7 +20,7 @@ import type { */ /** - * @returns a { frame: Frame | null, errors: null | ErrorMessages } object, extracting the frame metadata from the given htmlString. + * @returns an object, extracting the frame metadata from the given htmlString. * If the Frame fails validation, the `errors` object will be non-null */ export function getFrame({ @@ -35,21 +36,22 @@ export function getFrame({ const $ = cheerio.load(htmlString); let errors: null | Record = null; - function addError({ key, message }: { key: string; message: string }) { - if (!errors) errors = {}; - if ( - errors.hasOwnProperty(key) && - errors[key] && - Array.isArray(errors[key]) - ) { + function addError({ key, message }: { key: string; message: string }): void { + if (!errors) { + errors = {}; + } + + const error = errors[key]; + + if (error && Array.isArray(error)) { console.error(`Error: ${key} ${message}`); - errors[key]!.push(message); + error.push(message); } else { errors[key] = [message]; } } - function getMetaContent(key: string) { + function getMetaContent(key: string): string | undefined { const selector = `meta[property='${key}'], meta[name='${key}']`; const content = $(selector).attr("content"); if (content) return content; @@ -57,6 +59,7 @@ export function getFrame({ } const pageTitle = $("title").text(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- just in case if (pageTitle === undefined) { // This should probably be a warning instead of an error. would help addError({ @@ -81,9 +84,12 @@ export function getFrame({ }) .map((i, el) => { const attribute = $(el).attr("name") || $(el).attr("property"); - const id = attribute?.substring("of:accepts:".length)!; - const version = $(el).attr("content")!; - return { id, version }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is already checked in filter above + const id = attribute!.substring("of:accepts:".length); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is already checked in filter above + const detectedVersion = $(el).attr("content")!; + + return { id, version: detectedVersion }; }) .toArray(); @@ -98,7 +104,7 @@ export function getFrame({ `meta[property='fc:frame:button:${el}'], meta[name='fc:frame:button:${el}'], meta[property='of:button:${el}'], meta[name='of:button:${el}']` ) .map((i, elem) => parseButtonElement(elem)) - .filter((i, elem) => elem !== null) + .filter((i, elem) => Boolean(elem)) .toArray() // only take the first, to deduplicate of and fc:frame .slice(0, 1) @@ -108,7 +114,7 @@ export function getFrame({ `meta[property='fc:frame:button:${el}:action'], meta[name='fc:frame:button:${el}:action'], meta[property='of:button:${el}:action'], meta[name='of:button:${el}:action']` ) .map((i, elem) => parseButtonElement(elem)) - .filter((i, elem) => elem !== null) + .filter((i, elem) => Boolean(elem)) .toArray() // only take the first, to deduplicate of and fc:frame .slice(0, 1) @@ -119,7 +125,7 @@ export function getFrame({ `meta[property='fc:frame:button:${el}:target'], meta[name='fc:frame:button:${el}:target'], meta[property='of:button:${el}:target'], meta[name='of:button:${el}:target']` ) .map((i, elem) => parseButtonElement(elem)) - .filter((i, elem) => elem !== null) + .filter((i, elem) => Boolean(elem)) .toArray() // only take the first, to deduplicate of and fc:frame .slice(0, 1) @@ -130,23 +136,23 @@ export function getFrame({ `meta[property='fc:frame:button:${el}:post_url'], meta[name='fc:frame:button:${el}:post_url'], meta[property='of:button:${el}:post_url'], meta[name='of:button:${el}:post_url']` ) .map((i, elem) => parseButtonElement(elem)) - .filter((i, elem) => elem !== null) + .filter((i, elem) => Boolean(elem)) .toArray() // only take the first, to deduplicate of and fc:frame .slice(0, 1) ); - let buttonsValidation = [false, false, false, false]; + const buttonsValidation = [false, false, false, false]; const buttonsWithActions = buttonLabels .map((button): FrameButton & { buttonIndex: number } => { const buttonAction = buttonActions.find( - (action) => action?.buttonIndex === button?.buttonIndex + (action) => action.buttonIndex === button.buttonIndex ); const buttonTarget = buttonTargets.find( - (action) => action?.buttonIndex === button?.buttonIndex + (action) => action.buttonIndex === button.buttonIndex ); const buttonPostUrl = buttonPostUrls.find( - (action) => action?.buttonIndex === button?.buttonIndex + (action) => action.buttonIndex === button.buttonIndex ); if (buttonsValidation[button.buttonIndex - 1]) { addError({ @@ -164,7 +170,7 @@ export function getFrame({ } const action = - buttonAction?.content !== undefined ? buttonAction?.content : "post"; + buttonAction?.content !== undefined ? buttonAction.content : "post"; if (action === "link" || action === "tx") { if (!buttonTarget?.content) { addError({ @@ -240,7 +246,6 @@ export function getFrame({ return { label: button.label, action: button.action, - post_url: button.post_url, target: button.target, }; @@ -255,7 +260,6 @@ export function getFrame({ return { label: button.label, action: button.action, - post_url: button.post_url, target: button.target, }; }); @@ -279,14 +283,14 @@ export function getFrame({ }); if (!image) { addError({ message: "No image found in frame", key: "fc:frame:image" }); - } else if (!(image?.startsWith("http://") || image?.startsWith("https://"))) { + } else if (!(image.startsWith("http://") || image.startsWith("https://"))) { // validate image data url is not an svg if ( !( - image?.startsWith("data:image/png;base64,") || - image?.startsWith("data:image/jpg;base64,") || - image?.startsWith("data:image/jpeg;base64,") || - image?.startsWith("data:image/gif;base64,") + image.startsWith("data:image/png;base64,") || + image.startsWith("data:image/jpg;base64,") || + image.startsWith("data:image/jpeg;base64,") || + image.startsWith("data:image/gif;base64,") ) ) { if (image.startsWith("data:")) { @@ -356,7 +360,8 @@ export function getFrame({ return { frame: { version: version as "vNext" | `${number}-${number}-${number}`, - image: image!, + // @todo we should fix this + image: image ?? "", imageAspectRatio: imageAspectRatio as ImageAspectRatio, buttons: buttonsWithActions as FrameButtonsType, postUrl, @@ -368,17 +373,19 @@ export function getFrame({ }; } -export function parseButtonElement(elem: cheerio.Element) { - const nameAttr = elem.attribs["name"] || elem.attribs["property"]; +export function parseButtonElement( + elem: cheerio.Element +): { buttonIndex: number; content: string | undefined } | null { + const nameAttr = elem.attribs.name || elem.attribs.property; const buttonSegments = nameAttr?.split(":"); // Handles both cases of fc:frame:button:N and of:button:N const buttonIndex = - buttonSegments?.[0] === "fc" ? buttonSegments?.[3] : buttonSegments?.[2]; + buttonSegments?.[0] === "fc" ? buttonSegments[3] : buttonSegments?.[2]; try { return { buttonIndex: parseInt(buttonIndex || ""), - content: elem.attribs["content"], + content: elem.attribs.content, }; } catch (error) { return null; diff --git a/packages/frames.js/src/getFrameFlattened.test.ts b/packages/frames.js/src/getFrameFlattened.test.ts index 4486b3141..48e657a06 100644 --- a/packages/frames.js/src/getFrameFlattened.test.ts +++ b/packages/frames.js/src/getFrameFlattened.test.ts @@ -1,8 +1,8 @@ -import { Frame, getFrameFlattened, getTokenUrl } from "."; -import { zora } from "viem/chains"; +import type { Frame } from "."; +import { getFrameFlattened } from "."; describe("getFrameFlattened", () => { - it("should get flattened frame", async () => { + it("should get flattened frame", () => { const frame: Frame = { version: "vNext", image: "https://example.com/image.png", diff --git a/packages/frames.js/src/getFrameFlattened.ts b/packages/frames.js/src/getFrameFlattened.ts index 5ab321ee1..db5f3f269 100644 --- a/packages/frames.js/src/getFrameFlattened.ts +++ b/packages/frames.js/src/getFrameFlattened.ts @@ -2,41 +2,44 @@ import type { Frame, FrameFlattened } from "./types"; /** * Takes a `Frame` and formats it as an intermediate step before rendering as html - * @param frame The `Frame` to flatten + * @param frame - The `Frame` to flatten * @returns a plain object with frame metadata keys and values according to the frame spec, using their lengthened syntax, e.g. "fc:frame:image" */ export function getFrameFlattened(frame: Frame): FrameFlattened { - const openFrames = !!frame.accepts?.length - ? { - // custom of tags - [`of:version`]: frame.version, - ...frame.accepts?.reduce( - (acc: Record, { id, version }) => { - acc[`of:accepts:${id}`] = version; - return acc; - }, - {} - ), - // same as fc:frame tags - [`of:image`]: frame.image, - [`of:post_url`]: frame.postUrl, - [`of:input:text`]: frame.inputText, - ...(frame.state ? { [`of:state`]: frame.state } : {}), - ...(frame.imageAspectRatio - ? { [`of:image:aspect_ratio`]: frame.imageAspectRatio } - : {}), - ...frame.buttons?.reduce( - (acc, button, index) => ({ - ...acc, - [`of:button:${index + 1}`]: button.label, - [`of:button:${index + 1}:action`]: button.action, - [`of:button:${index + 1}:target`]: button.target, - [`of:button:${index + 1}:post_url`]: button.post_url, - }), - {} - ), - } - : {}; + const openFrames = + frame.accepts && Boolean(frame.accepts.length) + ? { + // custom of tags + [`of:version`]: frame.version, + ...frame.accepts.reduce( + (acc: Record, { id, version }) => { + acc[`of:accepts:${id}`] = version; + return acc; + }, + {} + ), + // same as fc:frame tags + [`of:image`]: frame.image, + [`of:post_url`]: frame.postUrl, + [`of:input:text`]: frame.inputText, + ...(frame.state ? { [`of:state`]: frame.state } : {}), + ...(frame.imageAspectRatio + ? { [`of:image:aspect_ratio`]: frame.imageAspectRatio } + : {}), + ...frame.buttons?.reduce( + (acc, button, index) => ({ + ...acc, + [`of:button:${index + 1}`]: button.label, + [`of:button:${index + 1}:action`]: button.action, + [`of:button:${index + 1}:target`]: button.target, + ...(button.action === "tx" + ? { [`of:button:${index + 1}:post_url`]: button.post_url } + : {}), + }), + {} + ), + } + : {}; const metadata: FrameFlattened = { [`fc:frame`]: frame.version, @@ -53,7 +56,9 @@ export function getFrameFlattened(frame: Frame): FrameFlattened { [`fc:frame:button:${index + 1}`]: button.label, [`fc:frame:button:${index + 1}:action`]: button.action, [`fc:frame:button:${index + 1}:target`]: button.target, - [`fc:frame:button:${index + 1}:post_url`]: button.post_url, + ...(button.action === "tx" + ? { [`fc:frame:button:${index + 1}:post_url`]: button.post_url } + : {}), }), {} ), diff --git a/packages/frames.js/src/getFrameHtml.ts b/packages/frames.js/src/getFrameHtml.ts index f152a1aa4..62800b521 100644 --- a/packages/frames.js/src/getFrameHtml.ts +++ b/packages/frames.js/src/getFrameHtml.ts @@ -1,4 +1,4 @@ -import { Frame } from "./types"; +import type { Frame } from "./types"; export interface GetFrameHtmlOptions { /** value for the OG "og:title" html tag*/ @@ -13,8 +13,8 @@ export interface GetFrameHtmlOptions { /** * Turns a `Frame` into html - * @param frame The Frame to turn into html - * @param options additional options passs into the html string + * @param frame - The Frame to turn into html + * @param options - additional options passs into the html string * @returns an html string */ export function getFrameHtml( @@ -36,7 +36,7 @@ export function getFrameHtml( /** * Formats a `Frame` ready to be included in a of an html string - * @param frame The `Frame` to get the contents for + * @param frame - The `Frame` to get the contents for * @returns an string with tags to be included in a */ export function getFrameHtmlHead(frame: Frame): string { @@ -54,13 +54,11 @@ export function getFrameHtmlHead(frame: Frame): string { : "", ...(frame.buttons?.flatMap((button, index) => [ ``, - button.action - ? `` - : "", + ``, button.target ? `` : "", - button.post_url + button.action === "tx" && button.post_url ? `` : "", ]) ?? []), @@ -81,21 +79,19 @@ export function getFrameHtmlHead(frame: Frame): string { : "", ...(frame.buttons?.flatMap((button, index) => [ ``, - button.action - ? `` - : "", + ``, button.target ? `` : "", - button.post_url + button.action === "tx" && button.post_url ? `` : "", ]) ?? []), ], - ...(frame.accepts?.map( + ...frame.accepts.map( ({ id, version }) => `` - ) ?? []) + ) ); } diff --git a/packages/frames.js/src/getFrameMessage.ts b/packages/frames.js/src/getFrameMessage.ts index f0a4bf1d6..1b14da6bf 100644 --- a/packages/frames.js/src/getFrameMessage.ts +++ b/packages/frames.js/src/getFrameMessage.ts @@ -1,5 +1,5 @@ -import { type FrameActionMessage, Message } from "./farcaster"; import { bytesToHex } from "viem/utils"; +import { type FrameActionMessage, Message } from "./farcaster"; import { normalizeCastId } from "./utils"; import type { FrameActionDataParsed, @@ -46,11 +46,8 @@ export async function getFrameMessage( inputText: inputTextBytes, state: stateBytes, transactionId: transactionIdBytes, - } = (decodedMessage.data - .frameActionBody as typeof decodedMessage.data.frameActionBody) || {}; - const inputText = inputTextBytes - ? Buffer.from(inputTextBytes).toString("utf-8") - : undefined; + } = decodedMessage.data.frameActionBody; + const inputText = Buffer.from(inputTextBytes).toString("utf-8"); const transactionId = transactionIdBytes.length > 0 ? bytesToHex(transactionIdBytes) : undefined; const requesterFid = decodedMessage.data.fid; @@ -63,9 +60,7 @@ export async function getFrameMessage( ? bytesToHex(decodedMessage.data.frameActionBody.address) : undefined; - const state = stateBytes - ? Buffer.from(stateBytes).toString("utf-8") - : undefined; + const state = Buffer.from(stateBytes).toString("utf-8"); const parsedData: FrameActionDataParsed = { buttonIndex, @@ -125,7 +120,12 @@ export async function getFrameMessage( const requesterCustodyAddress = requesterEthAddresses.find( (item) => item.type === "custody" - )!.address; + )?.address; + + if (!requesterCustodyAddress) { + throw new Error("Custody address not found"); + } + const requesterVerifiedAddresses = requesterEthAddresses .filter((item) => item.type === "verified") .map((item) => item.address); @@ -133,8 +133,8 @@ export async function getFrameMessage( // Perform actions to fetch the HubFrameContext and then return the combined result const hubContext: FrameActionHubContext = { isValid: validationResult.isValid, - casterFollowsRequester: casterFollowsRequester, - requesterFollowsCaster: requesterFollowsCaster, + casterFollowsRequester, + requesterFollowsCaster, likedCast, recastedCast, requesterVerifiedAddresses, @@ -142,7 +142,7 @@ export async function getFrameMessage( requesterUserData, }; return { ...parsedData, ...hubContext } as FrameMessageReturnType; - } else { - return parsedData as FrameMessageReturnType; } + + return parsedData as FrameMessageReturnType; } diff --git a/packages/frames.js/src/getTokenFromUrl.test.ts b/packages/frames.js/src/getTokenFromUrl.test.ts index c06438561..d4fa36748 100644 --- a/packages/frames.js/src/getTokenFromUrl.test.ts +++ b/packages/frames.js/src/getTokenFromUrl.test.ts @@ -1,7 +1,7 @@ import { getTokenFromUrl } from "."; describe("getTokenFromUrl", () => { - it("should get the token from the token url", async () => { + it("should get the token from the token url", () => { expect( getTokenFromUrl( "eip155:7777777:0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df:123" @@ -14,7 +14,7 @@ describe("getTokenFromUrl", () => { }); }); - it("throws for invalid url", async () => { + it("throws for invalid url", () => { expect(() => getTokenFromUrl("a")).toThrow("Invalid token URL"); }); }); diff --git a/packages/frames.js/src/getTokenFromUrl.ts b/packages/frames.js/src/getTokenFromUrl.ts index cf3b12d5b..84c1aa936 100644 --- a/packages/frames.js/src/getTokenFromUrl.ts +++ b/packages/frames.js/src/getTokenFromUrl.ts @@ -15,7 +15,7 @@ export function getTokenFromUrl(url: string): ParsedToken { } return { - namespace: namespace, + namespace, chainId: parseInt(chainId), address, tokenId: tokenId || undefined, diff --git a/packages/frames.js/src/getTokenUrl.test.ts b/packages/frames.js/src/getTokenUrl.test.ts index c3e35e920..68a7aeafc 100644 --- a/packages/frames.js/src/getTokenUrl.test.ts +++ b/packages/frames.js/src/getTokenUrl.test.ts @@ -1,8 +1,8 @@ -import { getTokenUrl } from "."; import { zora } from "viem/chains"; +import { getTokenUrl } from "."; describe("getTokenUrl", () => { - it("should get token url", async () => { + it("should get token url", () => { expect( getTokenUrl({ address: "0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df", @@ -20,7 +20,7 @@ describe("getTokenUrl", () => { ).toBe(`eip155:1:0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df:123`); }); - it("should get token url with chain object", async () => { + it("should get token url with chain object", () => { expect( getTokenUrl({ address: "0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df", diff --git a/packages/frames.js/src/getTokenUrl.ts b/packages/frames.js/src/getTokenUrl.ts index 14e119a2b..3cb58a9fb 100644 --- a/packages/frames.js/src/getTokenUrl.ts +++ b/packages/frames.js/src/getTokenUrl.ts @@ -1,4 +1,4 @@ -import { Chain } from "viem/chains"; +import type { Chain } from "viem/chains"; type GetTokenUrlParameters = { /** Address of smart contract on the given chain */ diff --git a/packages/frames.js/src/getUserDataForFid.test.ts b/packages/frames.js/src/getUserDataForFid.test.ts index cd3d7239e..ed633968d 100644 --- a/packages/frames.js/src/getUserDataForFid.test.ts +++ b/packages/frames.js/src/getUserDataForFid.test.ts @@ -1,10 +1,11 @@ -import nock from "nock"; -import { getUserDataForFid } from "."; +// eslint-disable-next-line import/no-extraneous-dependencies -- this is dev dependency +import nock, { cleanAll } from "nock"; import { DEFAULT_HUB_API_URL } from "./default"; +import { getUserDataForFid } from "."; describe("getUserDataForFid", () => { beforeEach(() => { - nock.cleanAll(); + cleanAll(); }); it("returns latest user data for fid", async () => { diff --git a/packages/frames.js/src/getUserDataForFid.ts b/packages/frames.js/src/getUserDataForFid.ts index 62b38f25e..ba81b58f2 100644 --- a/packages/frames.js/src/getUserDataForFid.ts +++ b/packages/frames.js/src/getUserDataForFid.ts @@ -1,6 +1,6 @@ import { DEFAULT_HUB_API_KEY, DEFAULT_HUB_API_URL } from "./default"; import { MessageType, UserDataType, Message } from "./farcaster"; -import { HubHttpUrlOptions, UserDataReturnType } from "./types"; +import type { HubHttpUrlOptions, UserDataReturnType } from "./types"; /** * Returns the latest user data for a given Farcaster users Fid if available. @@ -39,29 +39,31 @@ export async function getUserDataForFid< })) as { messages?: Record[] }; if (messages && messages.length > 0) { - const valuesByType = messages.reduce( - (acc, messageJson) => { - const message = Message.fromJSON(messageJson); + const valuesByType = messages.reduce< + Partial> + >((acc, messageJson) => { + const message = Message.fromJSON(messageJson); - if (message.data?.type !== MessageType.USER_DATA_ADD) { - return acc; - } - - const timestamp = message.data.timestamp; - const { type, value } = message.data.userDataBody!; + if (message.data?.type !== MessageType.USER_DATA_ADD) { + return acc; + } - if (!acc[type]) { - acc[type] = { value, timestamp }; - } else if (message.data.timestamp > acc[type]!.timestamp) { - acc[type] = { value, timestamp }; - } + if (!message.data.userDataBody) { return acc; - }, - {} as Record< - UserDataType, - undefined | { value: string; timestamp: number } - > - ); + } + + const timestamp = message.data.timestamp; + const { type, value } = message.data.userDataBody; + const foundValue = acc[type]; + + if (foundValue && foundValue.timestamp < timestamp) { + acc[type] = { value, timestamp }; + } else { + acc[type] = { value, timestamp }; + } + + return acc; + }, {}); return { profileImage: valuesByType[UserDataType.PFP]?.value, @@ -69,7 +71,7 @@ export async function getUserDataForFid< username: valuesByType[UserDataType.USERNAME]?.value, bio: valuesByType[UserDataType.BIO]?.value, }; - } else { - return null; } + + return null; } diff --git a/packages/frames.js/src/hono/index.test.tsx b/packages/frames.js/src/hono/index.test.tsx index 205b61c25..de3949743 100644 --- a/packages/frames.js/src/hono/index.test.tsx +++ b/packages/frames.js/src/hono/index.test.tsx @@ -8,12 +8,12 @@ describe("hono adapter", () => { it("correctly integrates with Hono", async () => { const frames = lib.createFrames(); - const handler = frames(async (ctx) => { + const handler = frames((ctx) => { expect(ctx.request.url).toBe("http://localhost:3000/"); return { image: Test, - buttons: [Click me], + buttons: [Click me], }; }); @@ -39,7 +39,7 @@ describe("hono adapter", () => { }, }); - const handler = frames(async (ctx) => { + const handler = frames((ctx) => { expect(ctx.state).toEqual({ test: false }); return { diff --git a/packages/frames.js/src/hono/index.ts b/packages/frames.js/src/hono/index.ts index f580d6f4c..71a7f953b 100644 --- a/packages/frames.js/src/hono/index.ts +++ b/packages/frames.js/src/hono/index.ts @@ -1,7 +1,9 @@ -export { Button, type types } from "../core"; -import { createFrames as coreCreateFrames, types } from "../core"; import type { Handler } from "hono"; -import { CoreMiddleware } from "../middleware"; +import type { types } from "../core"; +import { createFrames as coreCreateFrames } from "../core"; +import type { CoreMiddleware } from "../middleware"; + +export { Button, type types } from "../core"; type CreateFramesForHono = types.CreateFramesFunctionDefinition< CoreMiddleware, @@ -12,6 +14,7 @@ type CreateFramesForHono = types.CreateFramesFunctionDefinition< * Creates Frames instance to use with you Hono server * * @example + * ```tsx * import { createFrames, Button } from 'frames.js/hono'; * import { Hono } from 'hono'; * @@ -30,17 +33,24 @@ type CreateFramesForHono = types.CreateFramesFunctionDefinition< * const app = new Hono(); * * app.on(['GET', 'POST'], '/', honoHandler); + * ``` */ -// @ts-expect-error +// @ts-expect-error -- this code is correct just function doesn't satisfy the type export const createFrames: CreateFramesForHono = function createFramesForHono( - options?: types.FramesOptions + options?: types.FramesOptions ) { const frames = coreCreateFrames(options); return function honoFramesHandler< - TPerRouteMiddleware extends types.FramesMiddleware[], + TPerRouteMiddleware extends types.FramesMiddleware< + types.JsonValue | undefined, + Record + >[], >( - handler: types.FrameHandlerFunction, + handler: types.FrameHandlerFunction< + types.JsonValue | undefined, + Record + >, handlerOptions?: types.FramesRequestHandlerFunctionOptions ) { const framesHandler = frames(handler, handlerOptions); diff --git a/packages/frames.js/src/hono/test.types.tsx b/packages/frames.js/src/hono/test.types.tsx index 29502138d..43368bf2a 100644 --- a/packages/frames.js/src/hono/test.types.tsx +++ b/packages/frames.js/src/hono/test.types.tsx @@ -1,5 +1,7 @@ -import { Handler } from 'hono'; -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we are testing for promise compatibility */ +import type { Handler } from 'hono'; +import type { types } from '.'; +import { createFrames } from '.'; const framesWithoutState = createFrames(); framesWithoutState(async ctx => { @@ -38,8 +40,29 @@ framesWithExplicitState(async ctx => { test: boolean; }; ctx satisfies { - message?: any; - pressedButton?: any; + message?: unknown; + pressedButton?: unknown; + request: Request; + } + + return { + image: 'http://test.png' + }; +}) satisfies Handler; + +const framesWithExplicitStateNoPromise = createFrames<{ + test: boolean; +}>({}); +framesWithExplicitStateNoPromise(ctx => { + ctx.state satisfies { + test: boolean; + }; + ctx.initialState satisfies { + test: boolean; + }; + ctx satisfies { + message?: unknown; + pressedButton?: unknown; request: Request; } diff --git a/packages/frames.js/src/lib/stream-pump.ts b/packages/frames.js/src/lib/stream-pump.ts index 0e643ba00..d89dd54b6 100644 --- a/packages/frames.js/src/lib/stream-pump.ts +++ b/packages/frames.js/src/lib/stream-pump.ts @@ -27,16 +27,13 @@ export class StreamPump { new Stream.Readable().readableHighWaterMark; this.accumalatedSize = 0; this.stream = stream; - this.enqueue = this.enqueue.bind(this); - this.error = this.error.bind(this); - this.close = this.close.bind(this); } - size(chunk: Uint8Array) { - return chunk?.byteLength || 0; + size(chunk: Uint8Array): number { + return chunk.byteLength || 0; } - start(controller: ReadableStreamController) { + start(controller: ReadableStreamController): void { this.controller = controller; this.stream.on("data", this.enqueue); this.stream.once("error", this.error); @@ -44,11 +41,11 @@ export class StreamPump { this.stream.once("close", this.close); } - pull() { + pull(): void { this.resume(); } - cancel(reason?: Error) { + cancel(reason?: Error): void { if (this.stream.destroy) { this.stream.destroy(reason); } @@ -59,17 +56,17 @@ export class StreamPump { this.stream.off("close", this.close); } - enqueue(chunk: Uint8Array | string) { + enqueue = (chunk: Uint8Array | string): void => { if (this.controller) { try { - let bytes = chunk instanceof Uint8Array ? chunk : Buffer.from(chunk); + const bytes = chunk instanceof Uint8Array ? chunk : Buffer.from(chunk); - let available = (this.controller.desiredSize || 0) - bytes.byteLength; + const available = (this.controller.desiredSize || 0) - bytes.byteLength; this.controller.enqueue(bytes); if (available <= 0) { this.pause(); } - } catch (error: any) { + } catch (error) { this.controller.error( new Error( "Could not create Buffer, chunk must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object" @@ -78,53 +75,54 @@ export class StreamPump { this.cancel(); } } - } + }; - pause() { + pause(): void { if (this.stream.pause) { this.stream.pause(); } } - resume() { + resume(): void { if (this.stream.readable && this.stream.resume) { this.stream.resume(); } } - close() { + close = (): void => { if (this.controller) { this.controller.close(); delete this.controller; } - } + }; - error(error: Error) { + error = (error: Error): void => { if (this.controller) { this.controller.error(error); delete this.controller; } - } + }; } -export const createReadableStreamFromReadable = ( +export function createReadableStreamFromReadable( source: Readable & { readableHighWaterMark?: number } -) => { - let pump = new StreamPump(source); - let stream = new ReadableStream(pump, pump); +): ReadableStream { + const pump = new StreamPump(source); + const stream = new ReadableStream(pump, pump); return stream; -}; +} -export async function writeReadableStreamToWritable( - stream: ReadableStream, +export async function writeReadableStreamToWritable( + stream: ReadableStream, writable: Writable -) { - let reader = stream.getReader(); - let flushable = writable as { flush?: Function }; +): Promise { + const reader = stream.getReader(); + const flushable = writable as { flush?: () => void }; try { + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition -- this is expected to be exhaustive while (true) { - let { done, value } = await reader.read(); + const { done, value } = await reader.read(); if (done) { writable.end(); @@ -132,6 +130,7 @@ export async function writeReadableStreamToWritable( } writable.write(value); + if (typeof flushable.flush === "function") { flushable.flush(); } diff --git a/packages/frames.js/src/middleware/concurrentMiddleware.test.ts b/packages/frames.js/src/middleware/concurrentMiddleware.test.ts index b99208c55..b78f0a31a 100644 --- a/packages/frames.js/src/middleware/concurrentMiddleware.test.ts +++ b/packages/frames.js/src/middleware/concurrentMiddleware.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/require-await -- middleware is always async */ import { redirect } from "../core/redirect"; +import type { FramesContext, FramesMiddleware } from "../core/types"; import { concurrentMiddleware } from "./concurrentMiddleware"; describe("concurrentMiddleware", () => { @@ -7,16 +9,18 @@ describe("concurrentMiddleware", () => { }); it("returns middleware as is if only one is provided", () => { - const middleware = async () => {}; + const middleware: FramesMiddleware = async () => { + return new Response(); + }; - expect(concurrentMiddleware(middleware as any)).toBe(middleware); + expect(concurrentMiddleware(middleware)).toBe(middleware); }); it("returns a middleware that wraps all provided middlewares", () => { - const middleware1 = async (context: any, next: any) => { + const middleware1: FramesMiddleware = async (context, next) => { return next(); }; - const middleware2 = async (context: any, next: any) => { + const middleware2: FramesMiddleware = async (context, next) => { return next(); }; @@ -26,18 +30,21 @@ describe("concurrentMiddleware", () => { }); it("calls all provided middlewares and properly mutates context and then calls next middleware", async () => { - const middleware1 = async (context: any, next: any) => { - await new Promise((resolve) => setTimeout(resolve, 5)); + const middleware1: FramesMiddleware = async (context, next) => { + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); return next({ test1: true }); }; - const middleware2 = async (context: any, next: any) => { + const middleware2: FramesMiddleware = async (context, next) => { return next({ test2: true }); }; const parallelMiddleware = concurrentMiddleware(middleware1, middleware2); - let context: any = {}; + let context = {} as unknown as FramesContext; await parallelMiddleware(context, async (newCtx) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- we don't care really about the type of the value context = newCtx; return redirect("http://test.com"); }); diff --git a/packages/frames.js/src/middleware/concurrentMiddleware.ts b/packages/frames.js/src/middleware/concurrentMiddleware.ts index a3d834830..307b401af 100644 --- a/packages/frames.js/src/middleware/concurrentMiddleware.ts +++ b/packages/frames.js/src/middleware/concurrentMiddleware.ts @@ -17,12 +17,12 @@ export function concurrentMiddleware< throw new Error("No middlewares provided"); } - if (middlewares.length === 1) { - return middlewares[0]!; + if (middlewares.length === 1 && middlewares[0]) { + return middlewares[0]; } return async (context, next) => { - let ctx = context; + const ctx = context; const newContexts: any[] = []; await Promise.all( @@ -36,9 +36,10 @@ export function concurrentMiddleware< ) ); - let finalCtx = ctx; + let finalCtx: typeof ctx = ctx; for (const newCtx of newContexts) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- this is correct because we are merging objects finalCtx = { ...finalCtx, ...newCtx }; } diff --git a/packages/frames.js/src/middleware/farcaster.test.ts b/packages/frames.js/src/middleware/farcaster.test.ts index 512be0fb1..4bea6276a 100644 --- a/packages/frames.js/src/middleware/farcaster.test.ts +++ b/packages/frames.js/src/middleware/farcaster.test.ts @@ -1,10 +1,10 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- dev dependency +import { disableNetConnect, enableNetConnect } from "nock"; import { Message } from "../farcaster"; -import nock from "nock"; -import { FrameMessageReturnType } from "../getFrameMessage"; import { redirect } from "../core/redirect"; -import { FramesContext } from "../core/types"; -import { farcaster } from "./farcaster"; +import type { FramesContext } from "../core/types"; import { createFrames } from "../core"; +import { farcaster } from "./farcaster"; describe("farcaster middleware", () => { let sampleFrameActionRequest: Request; @@ -41,17 +41,17 @@ describe("farcaster middleware", () => { beforeEach(() => { // make sure we don't introduce unexpected network requests // TODO: Mock hub/onchain calls - nock.disableNetConnect(); + disableNetConnect(); }); afterEach(() => { - nock.enableNetConnect(); + enableNetConnect(); }); it("moves to next middleware without parsing if request is not POST request", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com"), - } as any; + } as unknown as FramesContext; const mw = farcaster(); const response = redirect("http://test.com"); @@ -62,12 +62,12 @@ describe("farcaster middleware", () => { }); it("moves to next middleware if request is POST but does not have a valid JSON body", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: "invalid json", }), - } as any; + } as unknown as FramesContext; const mw = farcaster(); const response = redirect("http://test.com"); @@ -78,12 +78,12 @@ describe("farcaster middleware", () => { }); it("moves to next middleware if request is POST with valid JSON but invalid body shape", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: JSON.stringify({}), }), - } as any; + } as unknown as FramesContext; const mw = farcaster(); const response = redirect("http://test.com"); @@ -94,33 +94,32 @@ describe("farcaster middleware", () => { }); it("parses frame message from request body and adds it to context", async () => { - const context: FramesContext = { + const context = { request: sampleFrameActionRequest.clone(), - } as any; + } as unknown as FramesContext; const mw = farcaster(); const next = jest.fn(() => Promise.resolve(new Response())); - nock.disableNetConnect(); + disableNetConnect(); await mw(context, next); - nock.enableNetConnect(); - - const { - message: calledArg, - }: { - message: FrameMessageReturnType<{ fetchHubContext: true }>; - } = (next.mock.calls[0]! as any)[0]; - - expect(calledArg.buttonIndex).toBe(1); - expect(calledArg.castId).toEqual({ - fid: 456, - hash: "0x", + enableNetConnect(); + + expect(next).toHaveBeenCalledWith({ + clientProtocol: { id: "farcaster", version: "vNext" }, + message: { + buttonIndex: 1, + castId: { + fid: 456, + hash: "0x", + }, + connectedAddress: "0x89", + inputText: "hello", + requesterFid: 123, + state: JSON.stringify({ test: true }), + transactionId: undefined, + }, }); - expect(calledArg.connectedAddress).toBe("0x89"); - expect(calledArg.inputText).toBe("hello"); - expect(calledArg.requesterFid).toBe(123); - expect(calledArg.state).toEqual(JSON.stringify({ test: true })); - expect(calledArg).not.toHaveProperty("requesterUserData"); }); it("supports custom global typed context", async () => { @@ -130,7 +129,7 @@ describe("farcaster middleware", () => { middleware: [mw1], }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { return { image: `/?fid=${ctx.message?.requesterFid}`, }; diff --git a/packages/frames.js/src/middleware/farcaster.ts b/packages/frames.js/src/middleware/farcaster.ts index 37e6a8ec1..266f37c45 100644 --- a/packages/frames.js/src/middleware/farcaster.ts +++ b/packages/frames.js/src/middleware/farcaster.ts @@ -14,7 +14,7 @@ function isValidFrameActionPayload( ): value is FrameActionPayload { return ( typeof value === "object" && - !!value && + value !== null && "trustedData" in value && "untrustedData" in value ); @@ -25,12 +25,12 @@ async function decodeFrameActionPayloadFromRequest( ): Promise { try { // use clone just in case someone wants to read body somewhere along the way - const body = await request + const body = (await request .clone() .json() .catch(() => { throw new RequestBodyNotJSONError(); - }); + })) as JsonValue; if (!isValidFrameActionPayload(body)) { throw new InvalidFrameActionPayloadError(); @@ -45,6 +45,7 @@ async function decodeFrameActionPayloadFromRequest( return undefined; } + // eslint-disable-next-line no-console -- provide feedback to the developer console.error(e); return undefined; diff --git a/packages/frames.js/src/middleware/farcasterHubContext.test.ts b/packages/frames.js/src/middleware/farcasterHubContext.test.ts index d1ee23b9c..c8ef00367 100644 --- a/packages/frames.js/src/middleware/farcasterHubContext.test.ts +++ b/packages/frames.js/src/middleware/farcasterHubContext.test.ts @@ -1,10 +1,9 @@ import { Message } from "../farcaster"; -import nock from "nock"; -import { FrameMessageReturnType } from "../getFrameMessage"; import { redirect } from "../core/redirect"; -import { FramesContext } from "../core/types"; -import { farcasterHubContext } from "./farcasterHubContext"; +import type { FramesContext } from "../core/types"; import { createFrames } from "../core"; +import type { UserDataReturnType } from ".."; +import { farcasterHubContext } from "./farcasterHubContext"; describe("farcasterHubContext middleware", () => { let sampleFrameActionRequest: Request; @@ -49,9 +48,9 @@ describe("farcasterHubContext middleware", () => { }); it("moves to next middleware without parsing if request is not POST request", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com"), - } as any; + } as unknown as FramesContext; const mw = farcasterHubContext(); const response = redirect("http://test.com"); @@ -62,12 +61,12 @@ describe("farcasterHubContext middleware", () => { }); it("moves to next middleware if request is POST but does not have a valid JSON body", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: "invalid json", }), - } as any; + } as unknown as FramesContext; const mw = farcasterHubContext(); const response = redirect("http://test.com"); @@ -78,12 +77,12 @@ describe("farcasterHubContext middleware", () => { }); it("moves to next middleware if request is POST with valid JSON but invalid body shape", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: JSON.stringify({}), }), - } as any; + } as unknown as FramesContext; const mw = farcasterHubContext(); const response = redirect("http://test.com"); @@ -94,31 +93,32 @@ describe("farcasterHubContext middleware", () => { }); it("parses frame message from request body and fetches external hub context and adds it to context", async () => { - const context: FramesContext = { + const context = { request: sampleFrameActionRequest.clone(), - } as any; + } as unknown as FramesContext; const mw = farcasterHubContext(); const next = jest.fn(() => Promise.resolve(new Response())); await mw(context, next); - const { - message: calledArg, - }: { - message: FrameMessageReturnType<{ fetchHubContext: true }>; - } = (next.mock.calls[0]! as any)[0]; - - expect(calledArg.buttonIndex).toBe(1); - expect(calledArg.castId).toEqual({ - fid: 456, - hash: "0x", - }); - expect(calledArg.connectedAddress).toBe("0x89"); - expect(calledArg.inputText).toBe("hello"); - expect(calledArg.requesterFid).toBe(123); - expect(calledArg.state).toEqual(JSON.stringify({ test: true })); - expect(calledArg).toHaveProperty("requesterUserData"); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + clientProtocol: { id: "farcaster", version: "vNext" }, + message: expect.objectContaining({ + buttonIndex: 1, + castId: { + fid: 456, + hash: "0x", + }, + connectedAddress: "0x89", + inputText: "hello", + requesterFid: 123, + state: JSON.stringify({ test: true }), + requesterUserData: expect.anything() as UserDataReturnType, + }) as unknown, + }) + ); }); it("supports custom global typed context", async () => { @@ -128,7 +128,7 @@ describe("farcasterHubContext middleware", () => { middleware: [mw1], }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { return { image: `/?username=${ctx.message?.requesterUserData?.username}`, }; diff --git a/packages/frames.js/src/middleware/farcasterHubContext.ts b/packages/frames.js/src/middleware/farcasterHubContext.ts index fbbd7c9b3..e068b26bd 100644 --- a/packages/frames.js/src/middleware/farcasterHubContext.ts +++ b/packages/frames.js/src/middleware/farcasterHubContext.ts @@ -18,7 +18,7 @@ function isValidFrameActionPayload( ): value is FrameActionPayload { return ( typeof value === "object" && - !!value && + value !== null && "trustedData" in value && "untrustedData" in value ); @@ -29,12 +29,12 @@ async function decodeFrameActionPayloadFromRequest( ): Promise { try { // use clone just in case someone wants to read body somewhere along the way - const body = await request + const body = (await request .clone() .json() .catch(() => { throw new RequestBodyNotJSONError(); - }); + })) as JSON; if (!isValidFrameActionPayload(body)) { throw new InvalidFrameActionPayloadError(); @@ -49,6 +49,7 @@ async function decodeFrameActionPayloadFromRequest( return undefined; } + // eslint-disable-next-line no-console -- provide feedback to the developer console.error(e); return undefined; diff --git a/packages/frames.js/src/middleware/framesjsMiddleware.test.ts b/packages/frames.js/src/middleware/framesjsMiddleware.test.ts index 6f37580e9..1943eb160 100644 --- a/packages/frames.js/src/middleware/framesjsMiddleware.test.ts +++ b/packages/frames.js/src/middleware/framesjsMiddleware.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console -- we are expecting console.log usage */ import { redirect } from "../core/redirect"; import type { FramesContext } from "../core/types"; import { generatePostButtonTargetURL } from "../core/utils"; @@ -5,10 +6,10 @@ import { framesjsMiddleware } from "./framesjsMiddleware"; describe("framesjsMiddleware middleware", () => { it("does not provide pressedButton to context if no supported button is detected", async () => { - const context: FramesContext = { + const context = { url: new URL("https://example.com"), request: new Request("https://example.com", { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(); const middleware = framesjsMiddleware(); @@ -31,10 +32,10 @@ describe("framesjsMiddleware middleware", () => { query: { test: true }, }, }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url, { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(); const middleware = framesjsMiddleware(); @@ -57,10 +58,10 @@ describe("framesjsMiddleware middleware", () => { currentURL: new URL("https://example.com"), target: "/test", }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url, { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(() => Promise.resolve(redirect("http://test.com"))); const middleware = framesjsMiddleware(); @@ -86,10 +87,10 @@ describe("framesjsMiddleware middleware", () => { currentURL: new URL("https://example.com"), target: "/test", }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url, { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(() => Promise.resolve(new Response(null, { status: 404 })) ); @@ -114,10 +115,10 @@ describe("framesjsMiddleware middleware", () => { query: { test: true }, }, }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url, { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(() => Promise.resolve(redirect("http://test.com"))); const middleware = framesjsMiddleware(); @@ -140,10 +141,10 @@ describe("framesjsMiddleware middleware", () => { query: { test: true }, }, }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url, { method: "POST" }), - } as any; + } as unknown as FramesContext; const next = jest.fn(() => Promise.resolve(new Response(null, { status: 200 })) ); @@ -167,11 +168,11 @@ describe("framesjsMiddleware middleware", () => { query: { test: true }, }, }); - const context: FramesContext = { + const context = { url: new URL(url), request: new Request(url), searchParams: {}, - } as any; + } as unknown as FramesContext; const next = jest.fn(); const middleware = framesjsMiddleware(); diff --git a/packages/frames.js/src/middleware/framesjsMiddleware.ts b/packages/frames.js/src/middleware/framesjsMiddleware.ts index d635a9bd6..dfd2a8d9a 100644 --- a/packages/frames.js/src/middleware/framesjsMiddleware.ts +++ b/packages/frames.js/src/middleware/framesjsMiddleware.ts @@ -7,9 +7,7 @@ import { type FramesjsMiddlewareContext = { /** the search params as an object. Empty will be an empty object */ - searchParams: { - [k: string]: string; - }; + searchParams: Record; /** * Button that was clicked on previous frame */ @@ -47,6 +45,7 @@ export function framesjsMiddleware(): FramesMiddleware< (result.status < 300 || result.status > 399)) || !isFrameRedirect(result) ) { + // eslint-disable-next-line no-console -- provide feedback to the developer console.warn( "The clicked button action was post_redirect, but the response was not a redirect" ); @@ -54,6 +53,7 @@ export function framesjsMiddleware(): FramesMiddleware< } else if (pressedButton?.action === "post") { // we support only frame definition as result for post button if (result instanceof Response || isFrameRedirect(result)) { + // eslint-disable-next-line no-console -- provide feedback to the developer console.warn( "The clicked button action was post, but the response was not a frame definition" ); diff --git a/packages/frames.js/src/middleware/openframes.test.ts b/packages/frames.js/src/middleware/openframes.test.ts index 64610b484..1a2beeaa7 100644 --- a/packages/frames.js/src/middleware/openframes.test.ts +++ b/packages/frames.js/src/middleware/openframes.test.ts @@ -1,35 +1,32 @@ import { FramesClient } from "@xmtp/frames-client"; -import { XmtpValidator } from "@xmtp/frames-validator"; -import { Client, Signer } from "@xmtp/xmtp-js"; -import { WalletClient, createWalletClient, http } from "viem"; +import type { Signer } from "@xmtp/xmtp-js"; +import { Client } from "@xmtp/xmtp-js"; +import type { WalletClient } from "viem"; +import { createWalletClient, http } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { mainnet } from "viem/chains"; -import { - XmtpFrameMessageReturnType, - getXmtpFrameMessage, - isXmtpFrameActionPayload, -} from "../xmtp"; +import type { XmtpFrameMessageReturnType } from "../xmtp"; +import { getXmtpFrameMessage, isXmtpFrameActionPayload } from "../xmtp"; import { redirect } from "../core/redirect"; -import { +import type { FrameDefinition, FramesContext, FramesMiddleware, } from "../core/types"; import { isFrameDefinition } from "../core/utils"; -import { OpenFramesMessageContext, openframes } from "./openframes"; -import { renderResponse } from "./renderResponse"; import { createFrames } from "../core"; +import type { OpenFramesMessageContext } from "./openframes"; +import { openframes } from "./openframes"; +import { renderResponse } from "./renderResponse"; -export function convertWalletClientToSigner( - walletClient: WalletClient -): Signer { +function convertWalletClientToSigner(walletClient: WalletClient): Signer { const { account } = walletClient; - if (!account || !account.address) { + if (!account) { throw new Error("WalletClient is not configured"); } return { - getAddress: async () => account.address, + getAddress: () => Promise.resolve(account.address), signMessage: async (message: string | Uint8Array) => walletClient.signMessage({ message: typeof message === "string" ? message : { raw: message }, @@ -41,7 +38,6 @@ export function convertWalletClientToSigner( describe("openframes middleware", () => { let xmtpClient: Client; let framesClient: FramesClient; - let xmtpValidator: XmtpValidator; let xmtpMiddleware: FramesMiddleware< any, @@ -66,8 +62,6 @@ describe("openframes middleware", () => { xmtpClient = await Client.create(signer, { env: "dev" }); framesClient = new FramesClient(xmtpClient); - xmtpValidator = new XmtpValidator(); - xmtpMiddleware = openframes({ clientProtocol: { id: "xmtp", @@ -81,10 +75,12 @@ describe("openframes middleware", () => { } const result = await getXmtpFrameMessage(body); - let state: any; + let state: (typeof result)["state"] | undefined; + try { - state = JSON.parse(result.state); + state = JSON.parse(result.state) as typeof state; } catch (error) { + // eslint-disable-next-line no-console -- we are expecting invalid state console.error("openframes: Could not parse state"); } @@ -111,21 +107,21 @@ describe("openframes middleware", () => { state: JSON.stringify({ test: true }), }); - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: JSON.stringify(signedPayload), }), url: new URL("https://example.com").toString(), basePath: "/", - } as any; + } as unknown as FramesContext; const mw1 = openframes({ clientProtocol: "foo@vNext", handler: { isValidPayload: () => false, - getFrameMessage: async () => { - return {}; + getFrameMessage: () => { + return Promise.resolve({}); }, }, }); @@ -133,14 +129,13 @@ describe("openframes middleware", () => { clientProtocol: "bar@vNext", handler: { isValidPayload: () => false, - getFrameMessage: async () => { - return {}; + getFrameMessage: () => { + return Promise.resolve({}); }, }, }); - // eslint-disable-next-line no-unused-vars - const next1 = jest.fn((...args) => + const next1 = jest.fn(() => Promise.resolve({ image: "/test.png", } as FrameDefinition) @@ -154,8 +149,7 @@ describe("openframes middleware", () => { version: "vNext", }); - // eslint-disable-next-line no-unused-vars - const next2 = jest.fn((...args) => Promise.resolve(nextResult1)); + const next2 = jest.fn(() => Promise.resolve(nextResult1)); const nextResult2 = await mw2(context, next2); @@ -169,9 +163,9 @@ describe("openframes middleware", () => { version: "vNext", }); + // eslint-disable-next-line testing-library/render-result-naming-convention -- this is not a react renderer const responseMw = renderResponse(); - // eslint-disable-next-line no-unused-vars - const responseNext = jest.fn((...args) => Promise.resolve(nextResult2)); + const responseNext = jest.fn(() => Promise.resolve(nextResult2)); const response = (await responseMw(context, responseNext)) as Response; const responseText = await response.text(); expect(responseText).toContain( @@ -183,9 +177,9 @@ describe("openframes middleware", () => { }); it("moves to next middleware without parsing if request is not POST request", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com"), - } as any; + } as unknown as FramesContext; const mw = xmtpMiddleware; const response = redirect("http://test.com"); @@ -196,12 +190,12 @@ describe("openframes middleware", () => { }); it("moves to next middleware if request is POST but does not have a valid JSON body", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: "invalid json", }), - } as any; + } as unknown as FramesContext; const mw = xmtpMiddleware; const response = redirect("http://test.com"); @@ -216,8 +210,8 @@ describe("openframes middleware", () => { clientProtocol: "foo@vNext", handler: { isValidPayload: () => false, - getFrameMessage: async () => { - return { test1: true }; + getFrameMessage: () => { + return Promise.resolve({ test1: true }); }, }, }); @@ -225,8 +219,8 @@ describe("openframes middleware", () => { clientProtocol: "bar@vNext", handler: { isValidPayload: () => true, - getFrameMessage: async () => { - return { test2: true }; + getFrameMessage: () => { + return Promise.resolve({ test2: true }); }, }, }); @@ -235,7 +229,7 @@ describe("openframes middleware", () => { middleware: [mw1, mw2], }); - const routeHandler = handler(async (ctx) => { + const routeHandler = handler((ctx) => { return { image: `/?test1=${ctx.message?.test1}&test2=${ctx.message?.test2}`, }; @@ -254,12 +248,12 @@ describe("openframes middleware", () => { }); it("moves to next middleware if request is POST with valid JSON but invalid body shape", async () => { - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: JSON.stringify({}), }), - } as any; + } as unknown as FramesContext; const mw = xmtpMiddleware; const response = redirect("http://test.com"); @@ -282,28 +276,30 @@ describe("openframes middleware", () => { state: JSON.stringify({ test: true }), }); - const context: FramesContext = { + const context = { request: new Request("https://example.com", { method: "POST", body: JSON.stringify(signedPayload), }), - } as any; + } as unknown as FramesContext; const mw = xmtpMiddleware; - // eslint-disable-next-line no-unused-vars - const next = jest.fn((...args) => Promise.resolve(new Response())); + const next = jest.fn(() => Promise.resolve(new Response())); await mw(context, next); - const calledArg: { clientProtocol: string; message: any } = - next.mock.calls[0]![0]; - - expect(calledArg.message.buttonIndex).toBe(buttonIndex); - expect(calledArg.message.inputText).toBe("hello"); - expect(calledArg.message.state).toMatchObject({ test: true }); - expect(calledArg.clientProtocol).toEqual({ - id: "xmtp", - version: "2024-02-09", - }); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + buttonIndex: 1, + inputText: "hello", + state: { test: true }, + }) as unknown, + clientProtocol: { + id: "xmtp", + version: "2024-02-09", + }, + }) + ); }); }); diff --git a/packages/frames.js/src/middleware/openframes.ts b/packages/frames.js/src/middleware/openframes.ts index 7805ae7ee..30e0345c1 100644 --- a/packages/frames.js/src/middleware/openframes.ts +++ b/packages/frames.js/src/middleware/openframes.ts @@ -1,6 +1,7 @@ import type { ClientProtocolId } from "../types"; import { RequestBodyNotJSONError } from "../core/errors"; import type { + FramesHandlerFunctionReturnType, FramesMiddleware, FramesMiddlewareReturnType, } from "../core/types"; @@ -14,21 +15,19 @@ export type OpenFramesMessageContext = { }; export type ClientProtocolHandler = { - // eslint-disable-next-line no-unused-vars getFrameMessage: (body: JSON) => Promise | undefined; - // eslint-disable-next-line no-unused-vars isValidPayload: (body: JSON) => boolean; }; async function cloneJsonBody(request: Request): Promise { try { // use clone just in case someone wants to read body somewhere along the way - const body = await request + const body = (await request .clone() .json() .catch(() => { throw new RequestBodyNotJSONError(); - }); + })) as JSON; return body; } catch (e) { @@ -36,6 +35,7 @@ async function cloneJsonBody(request: Request): Promise { return undefined; } + // eslint-disable-next-line no-console -- provide feedback to the developer console.error(e); return undefined; @@ -48,11 +48,13 @@ async function nextInjectAcceptedClient({ }: { nextResult: FramesMiddlewareReturnType; clientProtocol: ClientProtocolId; -}) { +}): Promise | Response> { const result = await nextResult; + if (isFrameDefinition(result)) { return { ...result, accepts: [...(result.accepts || []), clientProtocol] }; } + return result; } @@ -70,19 +72,30 @@ export function openframes({ handler: ClientProtocolHandler; }): FramesMiddleware> { return async (context, next) => { - const clientProtocol: ClientProtocolId = - typeof clientProtocolRaw === "string" - ? { - id: clientProtocolRaw.split("@")[0]!, - version: clientProtocolRaw.split("@")[1]!, - } - : clientProtocolRaw; + let clientProtocol: ClientProtocolId; + + if (typeof clientProtocolRaw === "string") { + const [id, version] = clientProtocolRaw.split("@"); + + if (!id || !version) { + throw new Error( + `Invalid client protocol string. Expected format is "id@version"` + ); + } + + clientProtocol = { + id, + version, + }; + } else { + clientProtocol = clientProtocolRaw; + } // frame message is available only if the request is a POST request if (context.request.method !== "POST") { return nextInjectAcceptedClient({ nextResult: next(), - clientProtocol: clientProtocol, + clientProtocol, }); } diff --git a/packages/frames.js/src/middleware/renderResponse.test.tsx b/packages/frames.js/src/middleware/renderResponse.test.tsx index 144f0ce4d..dcca8df36 100644 --- a/packages/frames.js/src/middleware/renderResponse.test.tsx +++ b/packages/frames.js/src/middleware/renderResponse.test.tsx @@ -1,9 +1,12 @@ +/* eslint-disable no-console -- we are expecting console usage */ +/* eslint-disable @typescript-eslint/require-await -- middleware expects async functions */ +/* eslint-disable testing-library/render-result-naming-convention -- we are not using react testing library here */ +import * as vercelOg from "@vercel/og"; import { FRAMES_META_TAGS_HEADER } from "../core"; import { Button } from "../core/components"; import { redirect } from "../core/redirect"; import type { FramesContext } from "../core/types"; import { renderResponse } from "./renderResponse"; -import * as vercelOg from "@vercel/og"; jest.mock("@vercel/og", () => { const arrayBufferMock = jest.fn(() => new ArrayBuffer(10)); @@ -18,9 +21,9 @@ jest.mock("@vercel/og", () => { arrayBufferMock, constructorMock, ImageResponse: class { - constructor(...args: any[]) { - // @ts-expect-error - return constructorMock(...args); + constructor(...args: unknown[]) { + // @ts-expect-error -- we are mocking the constructor + constructorMock(...args); } arrayBuffer = arrayBufferMock; }, @@ -28,8 +31,8 @@ jest.mock("@vercel/og", () => { }); describe("renderResponse middleware", () => { - let arrayBufferMock: jest.Mock = (vercelOg as any).arrayBufferMock; - let constructorMock: jest.Mock = (vercelOg as any).constructorMock; + const arrayBufferMock: jest.Mock = (vercelOg as unknown as { arrayBufferMock: jest.Mock }).arrayBufferMock; + const constructorMock: jest.Mock = (vercelOg as unknown as { constructorMock: jest.Mock }).constructorMock; const render = renderResponse(); const context: FramesContext = { basePath: "/", @@ -93,18 +96,21 @@ describe("renderResponse middleware", () => { buttons: [ , , , - , - , - , - , + , + , + , + , + , ], }; }); @@ -228,8 +236,8 @@ describe("renderResponse middleware", () => { return { image:
My image
, buttons: [ - // @ts-expect-error - , + // @ts-expect-error -- props are not matching the expected type + , ], }; }); @@ -242,7 +250,7 @@ describe("renderResponse middleware", () => { }); it("returns 500 if invalid button shape is provided", async () => { - // @ts-expect-error + // @ts-expect-error -- returns invalid object shape const result = await render(context, async () => { return { image:
My image
, @@ -283,9 +291,9 @@ describe("renderResponse middleware", () => { expect.any(Object), expect.any(Object) ); - expect(constructorMock.mock.calls[0][0]).toMatchSnapshot(); + expect((constructorMock.mock.calls[0] as unknown[])[0]).toMatchSnapshot(); - // @ts-expect-error + // @ts-expect-error -- we are modifying the context object newContext.something = "true"; result = await render(newContext, async () => { @@ -308,7 +316,7 @@ describe("renderResponse middleware", () => { expect.any(Object), expect.any(Object) ); - expect(constructorMock.mock.calls[1][0]).toMatchSnapshot(); + expect((constructorMock.mock.calls[1] as unknown[])[0]).toMatchSnapshot(); }); it("correctly renders tx button", async () => { @@ -321,14 +329,14 @@ describe("renderResponse middleware", () => { return { image:
My image
, buttons: [ - , ], }; }); - const json = await (result as Response).json(); + const json = await (result as Response).json() as Record; expect(json["fc:frame:button:1"]).toBe("Tx button"); expect(json["fc:frame:button:1:action"]).toBe("tx"); @@ -360,9 +368,9 @@ describe("renderResponse middleware", () => { expect(console.warn).toHaveBeenCalledTimes(1); - const json = await (result as Response).json(); + const json = await (result as Response).json() as Record; - expect(json["state"]).toBeUndefined(); + expect(json.state).toBeUndefined(); }); it("returns a state on POST requests (these are not initial since those are always reactions to clicks)", async () => { @@ -382,7 +390,7 @@ describe("renderResponse middleware", () => { expect(console.warn).not.toHaveBeenCalled(); - const json = await (result as Response).json(); + const json = await (result as Response).json() as Record; expect(console.warn).not.toHaveBeenCalled(); expect(json["fc:frame:state"]).toEqual(JSON.stringify({ test: true })); @@ -440,6 +448,7 @@ describe("renderResponse middleware", () => { buttons: [ , null, true, - , ], @@ -486,12 +496,12 @@ describe("renderResponse middleware", () => { expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(200); - const json = await (result as Response).json(); + const json = await (result as Response).json() as Record; expect(json).toMatchObject({ "fc:frame:button:1": "Click me 1", - "fc:frame:button:1:target": expect.any(String), + "fc:frame:button:1:target": expect.any(String) as string, "fc:frame:button:2": "Click me 2", - "fc:frame:button:2:target": expect.any(String), + "fc:frame:button:2:target": expect.any(String) as string, }); }); }); diff --git a/packages/frames.js/src/middleware/renderResponse.ts b/packages/frames.js/src/middleware/renderResponse.ts index 6659f404b..b40ac9f1e 100644 --- a/packages/frames.js/src/middleware/renderResponse.ts +++ b/packages/frames.js/src/middleware/renderResponse.ts @@ -14,7 +14,7 @@ import { generateTargetURL, isFrameRedirect, } from "../core/utils"; -import { FRAMES_META_TAGS_HEADER } from "../core"; +import { FRAMES_META_TAGS_HEADER } from "../core/constants"; class InvalidButtonShapeError extends Error {} @@ -30,15 +30,16 @@ class ImageRenderError extends Error {} * If the accept header is set to application/json, it will return the metadata as JSON * so it is easy to parse it for metatags in existing applications. */ -export function renderResponse(): FramesMiddleware { +export function renderResponse(): FramesMiddleware> { return async (context, next) => { const wantsJSON = context.request.headers.get("accept") === FRAMES_META_TAGS_HEADER; - let result: FramesHandlerFunctionReturnType | Response; + let result: FramesHandlerFunctionReturnType | Response | undefined; try { result = await next(context); } catch (e) { + // eslint-disable-next-line no-console -- provide feedback to the user console.error(e); return new Response( @@ -55,6 +56,7 @@ export function renderResponse(): FramesMiddleware { ); } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can happen if the handler returns undefined if (!result) { return new Response( wantsJSON @@ -91,6 +93,7 @@ export function renderResponse(): FramesMiddleware { if (context.request.method === "POST") { state = JSON.stringify(result.state); } else { + // eslint-disable-next-line no-console -- provide feedback to the user console.warn( "State is not supported on initial request (the one initialized when Frames are rendered for first time, that uses GET request) and will be ignored" ); @@ -116,6 +119,7 @@ export function renderResponse(): FramesMiddleware { }) : await renderImage(result.image, result.imageOptions).catch( (e) => { + // eslint-disable-next-line no-console -- provide feedback to the user console.error(e); throw new ImageRenderError("Could not render image"); @@ -124,7 +128,7 @@ export function renderResponse(): FramesMiddleware { buttons: result.buttons ?.slice() .filter( - (v: any): v is FrameButtonElement => v && typeof v === "object" + (v): v is FrameButtonElement => typeof v === "object" && v !== null ) .map((button, i): NonNullable[number] => { if (!("type" in button && "props" in button)) { @@ -221,7 +225,7 @@ export function renderResponse(): FramesMiddleware { }, }); } catch (e) { - let message: string = "Internal Server Error"; + let message = "Internal Server Error"; // do not expose any unrecognized errors in response, use console.error instead if ( @@ -232,6 +236,7 @@ export function renderResponse(): FramesMiddleware { ) { message = e.message; } else { + // eslint-disable-next-line no-console -- provide feedback to the user console.error(e); } diff --git a/packages/frames.js/src/middleware/stateMiddleware.test.ts b/packages/frames.js/src/middleware/stateMiddleware.test.ts index 111c1d529..f530b74b2 100644 --- a/packages/frames.js/src/middleware/stateMiddleware.test.ts +++ b/packages/frames.js/src/middleware/stateMiddleware.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-console -- we expect the usage of console.log */ +import type { FramesContext } from "../core/types"; import { stateMiddleware } from "./stateMiddleware"; describe("stateMiddleware", () => { @@ -10,7 +12,7 @@ describe("stateMiddleware", () => { const mw = stateMiddleware(); const next = jest.fn(); - await mw(ctx as any, next); + await mw(ctx as unknown as FramesContext, next); expect(next).toHaveBeenCalledWith({ state }); }); @@ -27,7 +29,7 @@ describe("stateMiddleware", () => { expect(console.warn).not.toHaveBeenCalled(); - await mw(ctx as any, next); + await mw(ctx as unknown as FramesContext, next); expect(console.warn).toHaveBeenCalled(); @@ -41,7 +43,7 @@ describe("stateMiddleware", () => { const mw = stateMiddleware(); const next = jest.fn(); - await mw(ctx as any, next); + await mw(ctx as unknown as FramesContext, next); expect(next).toHaveBeenCalledWith({ state: { initial: true } }); }); diff --git a/packages/frames.js/src/middleware/stateMiddleware.ts b/packages/frames.js/src/middleware/stateMiddleware.ts index 1da2dc5dd..c32d26304 100644 --- a/packages/frames.js/src/middleware/stateMiddleware.ts +++ b/packages/frames.js/src/middleware/stateMiddleware.ts @@ -23,18 +23,23 @@ export function stateMiddleware< ctx.message && "state" in ctx.message ) { - let state: TState = ctx.initialState as TState; + let state: TState = ctx.initialState; // since we are stringifyng state to JSON in renderResponse middleware, we need to parse decode JSON here // so it is easy to use in middleware chain and frames handler if (ctx.message.state) { try { - state = JSON.parse(ctx.message.state as unknown as string); + if (typeof ctx.message.state !== "string") { + throw new Error("State is not a string"); + } + + state = JSON.parse(ctx.message.state) as TState; } catch (e) { + // eslint-disable-next-line no-console -- provide feedback console.warn( "Failed to parse state from frame message, are you sure that the state was constructed by frames.js?" ); - state = ctx.initialState as TState; + state = ctx.initialState; } } @@ -44,7 +49,7 @@ export function stateMiddleware< } return next({ - state: ctx.initialState as TState, + state: ctx.initialState, }); }; } diff --git a/packages/frames.js/src/next/fetchMetadata.test.ts b/packages/frames.js/src/next/fetchMetadata.test.ts index 38dc57233..5b5063b36 100644 --- a/packages/frames.js/src/next/fetchMetadata.test.ts +++ b/packages/frames.js/src/next/fetchMetadata.test.ts @@ -1,14 +1,15 @@ -import nock from "nock"; +// eslint-disable-next-line import/no-extraneous-dependencies -- dev dependency +import nock, { enableNetConnect, disableNetConnect } from "nock"; +import type { FrameFlattened } from ".."; import { fetchMetadata } from "./fetchMetadata"; -import type { FrameFlattened } from "frames.js"; describe("fetchMetadata", () => { beforeAll(() => { - nock.disableNetConnect(); + disableNetConnect(); }); afterAll(() => { - nock.enableNetConnect(); + enableNetConnect(); }); it("returns metadata", async () => { diff --git a/packages/frames.js/src/next/fetchMetadata.ts b/packages/frames.js/src/next/fetchMetadata.ts index 8be82c3f8..06cb8b4ff 100644 --- a/packages/frames.js/src/next/fetchMetadata.ts +++ b/packages/frames.js/src/next/fetchMetadata.ts @@ -1,11 +1,12 @@ +import type { Metadata } from "next"; import type { FrameFlattened } from "../types"; import { FRAMES_META_TAGS_HEADER } from "../core"; -import type { Metadata } from "next"; /** * Fetches meta tags from your Frames app that can be used in Next.js generateMetadata() function. * * @example + * ```ts * import type { Metadata } from "next"; * import { fetchMetadata } from "frames.js/next"; * @@ -22,8 +23,9 @@ import type { Metadata } from "next"; * ), * }, * }; + * ``` * - * @param url Full URL of your Frames app + * @param url - Full URL of your Frames app */ export async function fetchMetadata( url: URL | string @@ -38,18 +40,23 @@ export async function fetchMetadata( cache: "no-cache", }); - if (response?.ok) { + if (response.ok) { // process the JSON value to nextjs compatible format - const flattenedFrame: FrameFlattened = await response.json(); + const flattenedFrame = (await response.json()) as FrameFlattened; return flattenedFrame as NonNullable; - } else if (response?.status) { + } else if (response.status) { + // eslint-disable-next-line no-console -- provide feedback console.warn( - `Failed to fetch frame metadata from ${url}. Status code: ${response.status}` + `Failed to fetch frame metadata from ${url.toString()}. Status code: ${response.status}` ); } } catch (error) { - console.warn(`Failed to fetch frame metadata from ${url}.`, error); + // eslint-disable-next-line no-console -- provide feedback + console.warn( + `Failed to fetch frame metadata from ${url.toString()}.`, + error + ); } return {}; diff --git a/packages/frames.js/src/next/getCurrentUrl.test.ts b/packages/frames.js/src/next/getCurrentUrl.test.ts index dd1008692..49441a002 100644 --- a/packages/frames.js/src/next/getCurrentUrl.test.ts +++ b/packages/frames.js/src/next/getCurrentUrl.test.ts @@ -1,9 +1,8 @@ -/* eslint-disable turbo/no-undeclared-env-vars */ import { getCurrentUrl } from "./getCurrentUrl"; describe("getCurrentUrl", () => { beforeEach(() => { - // @ts-expect-error + // @ts-expect-error -- this works process.env.NODE_ENV = "test"; }); @@ -38,7 +37,7 @@ describe("getCurrentUrl", () => { it("takes value from process.env.VERCEL_URL and uses https if NODE_ENV=production if available", () => { process.env.VERCEL_URL = "test.com"; - // @ts-expect-error + // @ts-expect-error -- this works process.env.NODE_ENV = "production"; expect( @@ -66,7 +65,7 @@ describe("getCurrentUrl", () => { it("takes value from process.env.APP_URL and uses https if NODE_ENV=production if available", () => { process.env.APP_URL = "test.com"; - // @ts-expect-error + // @ts-expect-error -- this works process.env.NODE_ENV = "production"; expect( diff --git a/packages/frames.js/src/next/getCurrentUrl.ts b/packages/frames.js/src/next/getCurrentUrl.ts index fcf35c982..76b750ab3 100644 --- a/packages/frames.js/src/next/getCurrentUrl.ts +++ b/packages/frames.js/src/next/getCurrentUrl.ts @@ -1,5 +1,5 @@ -import { IncomingMessage } from "http"; -import { NextApiRequest } from "next"; +import type { IncomingMessage } from "node:http"; +import type { NextApiRequest } from "next"; import type { NextRequest } from "next/server.js"; export function getCurrentUrl( @@ -7,20 +7,31 @@ export function getCurrentUrl( ): URL | undefined { const scheme = process.env.NODE_ENV === "production" ? "https://" : "http://"; const appUrl = process.env.APP_URL || process.env.VERCEL_URL; - const host: string | undefined = (req.headers as any)?.host; - - const pathname = req.url?.startsWith("/") - ? req.url - : req.url - ? new URL(req.url).pathname + new URL(req.url).search - : ""; + const host: string | undefined = ( + req.headers as unknown as Record | undefined + )?.host; + + let pathname: string; + + if (req.url?.startsWith("/")) { + pathname = req.url; + } else if (req.url) { + const url = new URL(req.url); + pathname = url.pathname + url.search; + } else { + pathname = ""; + } // Construct a valid URL from the Vercel URL environment variable if it exists - const parsedAppUrl = appUrl - ? appUrl.startsWith("http://") || appUrl.startsWith("https://") - ? new URL(pathname, appUrl) - : new URL(pathname, scheme + appUrl) - : undefined; + let parsedAppUrl: URL | undefined; + + if (appUrl) { + if (appUrl.startsWith("http://") || appUrl.startsWith("https://")) { + parsedAppUrl = new URL(pathname, appUrl); + } else { + parsedAppUrl = new URL(pathname, scheme + appUrl); + } + } // App URL if (parsedAppUrl) { @@ -40,8 +51,9 @@ export function getCurrentUrl( return undefined; } -function isValidUrl(url: string) { +function isValidUrl(url: string): boolean { try { + // eslint-disable-next-line no-new -- constructor will validate the URL new URL(url); return true; } catch (err) { diff --git a/packages/frames.js/src/next/index.test.ts b/packages/frames.js/src/next/index.test.ts index f13c781d2..a83524b60 100644 --- a/packages/frames.js/src/next/index.test.ts +++ b/packages/frames.js/src/next/index.test.ts @@ -1,5 +1,5 @@ -import * as lib from "."; import { NextRequest } from "next/server"; +import * as lib from "."; describe("next adapter", () => { it.each(["Button", "createFrames", "fetchMetadata"])( @@ -19,7 +19,7 @@ describe("next adapter", () => { }, }); - const handleRequest = frames(async (ctx) => { + const handleRequest = frames((ctx) => { expect(ctx.state).toEqual({ test: false }); return { diff --git a/packages/frames.js/src/next/index.ts b/packages/frames.js/src/next/index.ts index 8d79ebc79..06ddef69d 100644 --- a/packages/frames.js/src/next/index.ts +++ b/packages/frames.js/src/next/index.ts @@ -1,7 +1,9 @@ -import { createFrames as coreCreateFrames, types } from "../core"; import type { NextRequest, NextResponse } from "next/server"; +import type { types } from "../core"; +import { createFrames as coreCreateFrames } from "../core"; import type { CoreMiddleware } from "../middleware"; import { getCurrentUrl } from "./getCurrentUrl"; + export { Button, type types } from "../core"; export { fetchMetadata } from "./fetchMetadata"; @@ -15,6 +17,7 @@ type CreateFramesForNextJS = types.CreateFramesFunctionDefinition< * Creates Frames instance to use with you Next.js server * * @example + * ```tsx * import { createFrames, Button } from 'frames.js/next'; * import { NextApiRequest, NextApiResponse } from 'next'; * @@ -32,8 +35,9 @@ type CreateFramesForNextJS = types.CreateFramesFunctionDefinition< * * export const GET = nextHandler; * export const POST = nextHandler; + * ``` */ -// @ts-expect-error +// @ts-expect-error -- this is correct but the function does not satisfy the type export const createFrames: CreateFramesForNextJS = function createFramesForNextJS(options?: types.FramesOptions) { const frames = coreCreateFrames(options); diff --git a/packages/frames.js/src/next/pages-router.tsx b/packages/frames.js/src/next/pages-router.tsx index bf229f862..e2987a842 100644 --- a/packages/frames.js/src/next/pages-router.tsx +++ b/packages/frames.js/src/next/pages-router.tsx @@ -1,20 +1,26 @@ import type { Metadata, NextApiRequest, NextApiResponse } from "next"; +import React from "react"; +import type { types } from "../core"; import { createFrames as coreCreateFrames } from "../core"; import { createReadableStreamFromReadable, writeReadableStreamToWritable, } from "../lib/stream-pump"; + export { Button, type types } from "../core"; -import React from "react"; export { fetchMetadata } from "./fetchMetadata"; export const createFrames: typeof coreCreateFrames = - function createFramesForNextJSPagesRouter(options: any) { + function createFramesForNextJSPagesRouter(options: types.FramesOptions) { const frames = coreCreateFrames(options); - // @ts-expect-error - return function createHandler(handler, handlerOptions) { + return function createHandler< + TPerRouteMiddleware extends types.FramesMiddleware[], + >( + handler: types.FrameHandlerFunction, + handlerOptions?: types.FramesRequestHandlerFunctionOptions + ) { const requestHandler = frames(handler, handlerOptions); return async function handleNextJSApiRequest( @@ -31,6 +37,7 @@ export const createFrames: typeof coreCreateFrames = * Converts metadata returned from fetchMetadata() call to Next.js compatible components. * * @example + * ```tsx * import { fetchMetadata, metadataToMetaTags } from "frames.js/next/pages-router"; * * export const getServerSideProps = async function getServerSideProps() { @@ -54,8 +61,9 @@ export const createFrames: typeof coreCreateFrames = * * ); * } + * ``` */ -export function metadataToMetaTags(metadata: NonNullable) { +export function metadataToMetaTags(metadata: NonNullable): React.JSX.Element { return ( <> {Object.entries(metadata).map(([key, value]) => { @@ -76,21 +84,21 @@ function createRequest(req: NextApiRequest, res: NextApiResponse): Request { const normalizedXForwardedHost = Array.isArray(xForwardedHost) ? xForwardedHost[0] : xForwardedHost; - let [, hostnamePort] = normalizedXForwardedHost?.split(":") ?? []; - let [, hostPort] = req.headers["host"]?.split(":") ?? []; - let port = hostnamePort || hostPort; + const [, hostnamePort] = normalizedXForwardedHost?.split(":") ?? []; + const [, hostPort] = req.headers.host?.split(":") ?? []; + const port = hostnamePort || hostPort; // Use req.hostname here as it respects the "trust proxy" setting - let resolvedHost = `${req.headers["host"]}${!hostPort ? `:${port}` : ""}`; + const resolvedHost = `${req.headers.host}${!hostPort ? `:${port}` : ""}`; // Use `req.url` so NextJS is aware of the full path - let url = new URL( + const url = new URL( `${"encrypted" in req.socket && req.socket.encrypted ? "https" : "http"}://${resolvedHost}${req.url}` ); // Abort action/loaders once we can no longer write a response - let controller = new AbortController(); - res.on("close", () => controller.abort()); + const controller = new AbortController(); + res.on("close", () => { controller.abort(); }); - let init: RequestInit = { + const init: RequestInit = { method: req.method, headers: createRequestHeaders(req.headers), signal: controller.signal, @@ -107,12 +115,12 @@ function createRequest(req: NextApiRequest, res: NextApiResponse): Request { export function createRequestHeaders( requestHeaders: NextApiRequest["headers"] ): Headers { - let headers = new Headers(); + const headers = new Headers(); - for (let [key, values] of Object.entries(requestHeaders)) { + for (const [key, values] of Object.entries(requestHeaders)) { if (values) { if (Array.isArray(values)) { - for (let value of values) { + for (const value of values) { headers.append(key, value); } } else { @@ -124,11 +132,11 @@ export function createRequestHeaders( return headers; } -async function sendResponse(res: NextApiResponse, response: Response) { +async function sendResponse(res: NextApiResponse, response: Response): Promise { res.statusMessage = response.statusText; res.status(response.status); - for (let [key, value] of response.headers.entries()) { + for (const [key, value] of response.headers.entries()) { res.setHeader(key, value); } diff --git a/packages/frames.js/src/next/server.tsx b/packages/frames.js/src/next/server.tsx index 2b4f945ac..ca2fea25c 100644 --- a/packages/frames.js/src/next/server.tsx +++ b/packages/frames.js/src/next/server.tsx @@ -1,10 +1,11 @@ -import { FrameActionMessage } from "../farcaster"; import { headers } from "next/headers"; +import { ImageResponse } from "@vercel/og"; import { type NextRequest, NextResponse as NextResponseBase, } from "next/server"; -import React from "react"; +import React, {Children, isValidElement} from "react"; +import type { FrameActionMessage } from "../farcaster"; import { type FrameMessageReturnType, type GetFrameMessageOptions, @@ -31,10 +32,9 @@ import type { RedirectMap, RedirectHandler, } from "./types"; + export * from "./types"; -import { ImageResponse } from "@vercel/og"; -import type { SatoriOptions } from "satori"; // this is ugly hack to go around the issue https://github.com/vercel/next.js/pull/61721 const NextResponse = ( @@ -60,7 +60,7 @@ export async function validateActionSignature( if (options?.hubHttpUrl) { if (!options.hubHttpUrl.startsWith("http")) { throw new Error( - `frames.js: Invalid Hub URL: ${options?.hubHttpUrl}, ensure you have included the protocol (e.g. https://)` + `frames.js: Invalid Hub URL: ${options.hubHttpUrl}, ensure you have included the protocol (e.g. https://)` ); } } @@ -95,12 +95,13 @@ export async function getFrameMessage( if (options?.hubHttpUrl) { if (!options.hubHttpUrl.startsWith("http")) { throw new Error( - `frames.js: Invalid Hub URL: ${options?.hubHttpUrl}, ensure you have included the protocol (e.g. https://)` + `frames.js: Invalid Hub URL: ${options.hubHttpUrl}, ensure you have included the protocol (e.g. https://)` ); } } if (!frameActionPayload) { + // eslint-disable-next-line no-console -- provide a feedback to user on server console.log( "info: no frameActionPayload, this is expected for the homeframe" ); @@ -110,10 +111,6 @@ export async function getFrameMessage( const result = await _getFrameMessage(frameActionPayload, options); - if (!result) { - throw new Error("frames.js: something went wrong getting frame message"); - } - return result; } /** @@ -149,11 +146,11 @@ export function createPreviousFrame( PreviousFrame, "postBody" | "prevState" | "pathname" | "prevRedirects" >, - headers: HeadersList + headersList: HeadersList ): PreviousFrame { return { ...previousFrameFromParams, - headers: headers, + headers: headersList, }; } @@ -169,39 +166,39 @@ export function parseFrameParams( "postBody" | "prevState" | "pathname" | "prevRedirects" > { const frameActionReceived = - searchParams?.postBody && typeof searchParams?.postBody === "string" - ? (JSON.parse(searchParams?.postBody) as FrameActionPayload) + searchParams?.postBody && typeof searchParams.postBody === "string" + ? (JSON.parse(searchParams.postBody) as FrameActionPayload) : null; const framePrevState = - searchParams?.prevState && typeof searchParams?.prevState === "string" - ? (JSON.parse(searchParams?.prevState) as T) + searchParams?.prevState && typeof searchParams.prevState === "string" + ? (JSON.parse(searchParams.prevState) as T) : null; const framePrevRedirects = searchParams?.prevRedirects && - typeof searchParams?.prevRedirects === "string" - ? (JSON.parse(searchParams?.prevRedirects) as RedirectMap) + typeof searchParams.prevRedirects === "string" + ? (JSON.parse(searchParams.prevRedirects) as RedirectMap) : null; const pathname = - searchParams?.pathname && typeof searchParams?.pathname === "string" - ? searchParams?.pathname + searchParams?.pathname && typeof searchParams.pathname === "string" + ? searchParams.pathname : undefined; return { postBody: frameActionReceived, prevState: framePrevState, - pathname: pathname, + pathname, prevRedirects: framePrevRedirects, }; } /** * - * @param reducer a function taking a state and action and returning another action. This reducer is always called in the Frame to compute the state by calling it with the previous Frame + action - * @param initialState the initial state to use if there was no previous action - * @param initializerArg the previousFrame object to use to initialize the state + * @param reducer - a function taking a state and action and returning another action. This reducer is always called in the Frame to compute the state by calling it with the previous Frame + action + * @param initialState - the initial state to use if there was no previous action + * @param initializerArg - the previousFrame object to use to initialize the state * @returns An array of [State, Dispatch] where State is your reducer state, and dispatch is a function that doesn't do anything atm * * @deprecated please upgrade to new API, see https://framesjs.org/reference/core/next. @@ -219,12 +216,14 @@ export function useFramesReducer( } // doesn't do anything right now, but exists to make Button onClicks feel more natural and not magic. - function dispatch(actionIndex: ActionIndex) {} + function dispatch(_actionIndex: ActionIndex): void { + return undefined; + } return [frameReducerInit(initializerArg), dispatch]; } -function toUrl(req: NextRequest) { +function toUrl(req: NextRequest): URL { const reqUrl = new URL(req.url); if (process.env.NEXT_PUBLIC_HOST) { @@ -249,6 +248,7 @@ function toUrl(req: NextRequest) { reqUrl.host = host || reqUrl.host; reqUrl.port = port; + // eslint-disable-next-line no-console -- provide feedback to user on server console.info( `frames.js: NEXT_PUBLIC_HOST not set, using x-forwarded-* headers for redirect (${reqUrl.toString()})` ); @@ -259,7 +259,7 @@ function toUrl(req: NextRequest) { /** * A function ready made for next.js in order to directly export it, which handles all incoming `POST` requests that apps will trigger when users press buttons in your Frame. * It handles all the redirecting for you, correctly, based on the props defined by the Frame that triggered the user action. - * @param req a `NextRequest` object from `next/server` (Next.js app router server components) + * @param req - a `NextRequest` object from `next/server` (Next.js app router server components) * @returns NextResponse * * @deprecated please upgrade to new API, see https://framesjs.org/reference/core/next. @@ -269,15 +269,20 @@ export async function POST( /** unused, but will most frequently be passed a res: NextResponse object. Should stay in here for easy consumption compatible with next.js */ res: typeof NextResponse, redirectHandler?: RedirectHandler -) { - const body = await req.json(); +): Promise { + const body = await req.json() as FrameActionPayload; const url = new URL(req.url); let newUrl = toUrl(req); - const isFullUrl = - url.searchParams.get("p")?.startsWith("http://") || - url.searchParams.get("p")?.startsWith("https://"); - if (isFullUrl) newUrl = new URL(url.searchParams.get("p")!); + const pathFromRequest = url.searchParams.get("p"); + + const isFullUrl = typeof pathFromRequest === 'string' && ( + pathFromRequest.startsWith("http://") || + pathFromRequest.startsWith("https://")); + + if (isFullUrl) { + newUrl = new URL(pathFromRequest); + } else newUrl.pathname = url.searchParams.get("p") || ""; // decompress from 256 bytes limitation of post_url @@ -293,27 +298,20 @@ export async function POST( Object.fromEntries(url.searchParams.entries()) ); + const clickedButtonIndex = prevFrame.postBody?.untrustedData.buttonIndex; + const clickedButtonHref = clickedButtonIndex ? prevFrame.prevRedirects?.[clickedButtonIndex] : null; + // Handle 'post_redirect' buttons with href values - if ( - prevFrame.postBody?.untrustedData.buttonIndex && - prevFrame.prevRedirects?.hasOwnProperty( - prevFrame.postBody?.untrustedData.buttonIndex - ) && - prevFrame.prevRedirects[prevFrame.postBody?.untrustedData.buttonIndex] - ) { + if (clickedButtonHref) { return NextResponse.redirect( - prevFrame.prevRedirects[ - `${prevFrame.postBody?.untrustedData.buttonIndex}` - ]!, + clickedButtonHref, { status: 302 } ); } // Handle 'post_redirect' buttons without defined href values if ( prevFrame.postBody?.untrustedData.buttonIndex && - prevFrame.prevRedirects?.hasOwnProperty( - `_${prevFrame.postBody?.untrustedData.buttonIndex}` - ) + prevFrame.prevRedirects && `_${prevFrame.postBody.untrustedData.buttonIndex}` in prevFrame.prevRedirects ) { if (!redirectHandler) { // Error! @@ -398,7 +396,7 @@ function isElementFrameInput( * * A React functional component that Wraps a Frame and processes it, validating certain properties of the Frames spec, as well as adding other props. It also generates the postUrl. * It throws an error if the Frame is invalid, which can be caught by using an error boundary. - * @param param0 + * * @returns React.JSXElement */ export function FrameContainer({ @@ -412,7 +410,7 @@ export function FrameContainer({ /** Either a relative e.g. "/frames" or an absolute path, e.g. "https://google.com/frames" */ postUrl: string; /** The elements to include in the Frame */ - children: Array | ChildType; + children: (ChildType | null)[] | ChildType; /** The current reducer state object, returned from useFramesReducer */ state: T; previousFrame: PreviousFrame; @@ -420,11 +418,13 @@ export function FrameContainer({ pathname?: string; /** Client protocols to accept */ accepts?: ClientProtocolId[]; -}) { - if (!pathname) +}): React.JSX.Element { + if (!pathname) { + // eslint-disable-next-line no-console -- provide feedback to user on server console.warn( "frames.js: warning: You have not specified a `pathname` prop on your . This is not recommended, as it will default to the root path and not work if your frame is being rendered at a different path. Please specify a `pathname` prop on your ." ); + } const nextIndexByComponentType: Record< "button" | "image" | "input", @@ -434,7 +434,7 @@ export function FrameContainer({ image: 1, input: 1, }; - let redirectMap: RedirectMap = {}; + const redirectMap: RedirectMap = {}; function createURLForPOSTHandler(target: string): URL { let url: URL; @@ -460,10 +460,10 @@ export function FrameContainer({ const newTree = ( <> - {React.Children.map(children, (child) => { + {Children.map(children, (child) => { if (child === null) return; - if (!React.isValidElement(child)) { + if (!isValidElement(child)) { return child; } @@ -526,7 +526,7 @@ export function FrameContainer({ const rewrittenProps: React.ComponentProps = { - ...(child.props as React.ComponentProps), + ...child.props, ...(target ? { target: target.toString() } : {}), actionIndex: nextIndexByComponentType.button++ as ActionIndex, }; @@ -574,6 +574,7 @@ export function FrameContainer({ const postUrlFull = `${postUrlRoute}?${searchParams.toString()}`; if (getByteLength(postUrlFull) > 256) { + // eslint-disable-next-line no-console -- provide feedback to user on server console.error( `post_url is too long. ${postUrlFull.length} bytes, max is 256. The url is generated to include your state and the redirect urls in ({ {...accepts?.map(({ id, version }) => ( - + )) ?? []} {newTree} @@ -598,7 +599,7 @@ export function FrameContainer({ * * @deprecated please upgrade to new API, see https://framesjs.org/reference/core/next */ -export function FrameButton(props: FrameButtonProvidedProps) { +export function FrameButton(_props: FrameButtonProvidedProps): React.JSX.Element | null { return null; } @@ -607,9 +608,9 @@ function FFrameButtonShim({ actionIndex, target, action = "post", - post_url, + post_url: postUrl, children, -}: FrameButtonProvidedProps & FrameButtonAutomatedProps) { +}: FrameButtonProvidedProps & FrameButtonAutomatedProps): React.JSX.Element { return ( <> ) : null} - {post_url ? ( + { + postUrl ? ( ) : null} @@ -635,11 +637,9 @@ function FFrameButtonShim({ * * @deprecated please upgrade to new API, see https://framesjs.org/reference/core/next */ -export function FrameInput({ text }: { text: string }) { +export function FrameInput({ text }:{ text: string }): React.JSX.Element { return ( - <> - - + ); } @@ -659,10 +659,10 @@ export async function FrameImage( | { /** Children to pass to satori to render to PNG. [Supports tailwind](https://vercel.com/blog/introducing-vercel-og-image-generation-fast-dynamic-social-card-images#tailwind-css-support) via the `tw=` prop instead of `className` */ children: React.ReactNode; - options?: SatoriOptions; + options?: ConstructorParameters[1]; } ) -) { +): Promise { let imgSrc: string; if ("children" in props) { @@ -710,7 +710,7 @@ export async function FrameImage( ), imageOptions ); - const imgBuffer = await imageResponse?.arrayBuffer(); + const imgBuffer = await imageResponse.arrayBuffer(); imgSrc = `data:image/png;base64,${Buffer.from(imgBuffer).toString("base64")}`; } else { imgSrc = props.src; @@ -720,11 +720,11 @@ export async function FrameImage( <> - {props.aspectRatio && ( + {Boolean(props.aspectRatio) && ( + /> )} ); diff --git a/packages/frames.js/src/next/test.types.tsx b/packages/frames.js/src/next/test.types.tsx index f808040f4..4ac29a520 100644 --- a/packages/frames.js/src/next/test.types.tsx +++ b/packages/frames.js/src/next/test.types.tsx @@ -1,5 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we want to test that types are compatible with promises */ +import type { NextRequest, NextResponse } from 'next/server'; +import type { types } from '.'; +import { createFrames } from '.'; type Handler = (req: NextRequest) => Promise; @@ -40,8 +42,29 @@ framesWithExplicitState(async ctx => { test: boolean; }; ctx satisfies { - message?: any; - pressedButton?: any; + message?: unknown; + pressedButton?: unknown; + request: Request; + } + + return { + image: 'http://test.png' + }; +}) satisfies Handler; + +const framesWithExplicitStateNoPromise = createFrames<{ + test: boolean; +}>({}); +framesWithExplicitStateNoPromise(ctx => { + ctx.state satisfies { + test: boolean; + }; + ctx.initialState satisfies { + test: boolean; + }; + ctx satisfies { + message?: unknown; + pressedButton?: unknown; request: Request; } diff --git a/packages/frames.js/src/next/types.ts b/packages/frames.js/src/next/types.ts index d397e5b91..1e87a55f8 100644 --- a/packages/frames.js/src/next/types.ts +++ b/packages/frames.js/src/next/types.ts @@ -19,10 +19,11 @@ export type HeadersList = { /** A subset of JS objects that are serializable */ type AnyJson = boolean | number | string | null | JsonArray | JsonMap; +// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style -- this is necessary interface JsonMap { [key: string]: AnyJson; } -interface JsonArray extends Array {} +type JsonArray = AnyJson[]; /** * FrameState constraints @@ -78,7 +79,7 @@ export type FrameButtonAutomatedProps = { /** * Does nothing at the moment, but may be used in the future. It also makes the syntax more logical, as it seems like `Button` `onClick` actually dispatches a state transition, although in reality it works differently. */ -export type Dispatch = (actionIndex: ActionIndex) => any; +export type Dispatch = (actionIndex: ActionIndex) => unknown; export type FrameButtonProvidedProps = | FrameButtonPostRedirectProvidedProps @@ -135,6 +136,6 @@ export type FrameButtonMintProvidedProps = { /** See https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional */ export type NextServerPageProps = { - params: {}; - searchParams?: { [key: string]: string | string[] | undefined }; + params: Record; + searchParams?: Record; }; diff --git a/packages/frames.js/src/remix/fetchMetadata.test.ts b/packages/frames.js/src/remix/fetchMetadata.test.ts index 3c909d013..6c8582580 100644 --- a/packages/frames.js/src/remix/fetchMetadata.test.ts +++ b/packages/frames.js/src/remix/fetchMetadata.test.ts @@ -1,14 +1,15 @@ -import nock from "nock"; -import { fetchMetadata } from "./fetchMetadata"; +// eslint-disable-next-line import/no-extraneous-dependencies -- this is dev dependency of root package +import nock, { disableNetConnect, enableNetConnect } from "nock"; import type { FrameFlattened } from ".."; +import { fetchMetadata } from "./fetchMetadata"; describe("fetchMetaData", () => { beforeAll(() => { - nock.disableNetConnect(); + disableNetConnect(); }); afterAll(() => { - nock.enableNetConnect(); + enableNetConnect(); }); it("returns correct metadata from flattened frame", async () => { diff --git a/packages/frames.js/src/remix/fetchMetadata.ts b/packages/frames.js/src/remix/fetchMetadata.ts index 08d5d1935..751f88048 100644 --- a/packages/frames.js/src/remix/fetchMetadata.ts +++ b/packages/frames.js/src/remix/fetchMetadata.ts @@ -1,6 +1,6 @@ +import type { MetaFunction } from "@remix-run/node"; import type { FrameFlattened } from "../types"; import { FRAMES_META_TAGS_HEADER } from "../core"; -import type { MetaFunction } from "@remix-run/node"; type Metadata = ReturnType; @@ -8,6 +8,7 @@ type Metadata = ReturnType; * Fetches meta tags from your Frames app that can be used in Remix meta() function. * * @example + * ```ts * import { fetchMetadata } from "frames.js/remix"; * * export async function loader({ request }: LoaderFunctionArgs) { @@ -22,8 +23,9 @@ type Metadata = ReturnType; * ...data.metaTags, * ]; * }; + * ``` * - * @param url Full URL of your Frames app + * @param url - Full URL of your Frames app */ export async function fetchMetadata(url: URL | string): Promise { const response = await fetch(url, { @@ -36,7 +38,8 @@ export async function fetchMetadata(url: URL | string): Promise { if (response.ok) { // process the JSON value to nextjs compatible format - const flattenedFrame: FrameFlattened = await response.json(); + // @todo we should validate the response shape + const flattenedFrame = (await response.json()) as FrameFlattened; // convert to remix compatible shape return Object.entries(flattenedFrame).map(([key, value]) => { @@ -48,6 +51,6 @@ export async function fetchMetadata(url: URL | string): Promise { } throw new Error( - `Failed to fetch frames metadata from ${url}. The server returned ${response.status} ${response.statusText} response.` + `Failed to fetch frames metadata from ${url.toString()}. The server returned ${response.status} ${response.statusText} response.` ); } diff --git a/packages/frames.js/src/remix/index.test.ts b/packages/frames.js/src/remix/index.test.ts index 582611120..6a997e7d8 100644 --- a/packages/frames.js/src/remix/index.test.ts +++ b/packages/frames.js/src/remix/index.test.ts @@ -18,7 +18,7 @@ describe("remix adapter", () => { }, }); - const handleRequest = frames(async (ctx) => { + const handleRequest = frames((ctx) => { expect(ctx.state).toEqual({ test: false }); return { diff --git a/packages/frames.js/src/remix/index.ts b/packages/frames.js/src/remix/index.ts index 7a17f14a3..35772c3b9 100644 --- a/packages/frames.js/src/remix/index.ts +++ b/packages/frames.js/src/remix/index.ts @@ -1,6 +1,8 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { createFrames as coreCreateFrames, types } from "../core"; +import type { types } from "../core"; +import { createFrames as coreCreateFrames } from "../core"; import type { CoreMiddleware } from "../middleware"; + export { Button, type types } from "../core"; export { fetchMetadata } from "./fetchMetadata"; @@ -14,6 +16,7 @@ type CreateFramesForRemix = types.CreateFramesFunctionDefinition< * Creates Frames instance to use with you Remix server * * @example + * ```tsx * import { createFrames, Button } from 'frames.js/remix'; * import type { LoaderFunction, ActionFunction } from '@remix-run/node'; * @@ -31,8 +34,9 @@ type CreateFramesForRemix = types.CreateFramesFunctionDefinition< * * export const loader: LoaderFunction = remixHandler; * export const action: ActionFunction = remixHandler; + * ``` */ -// @ts-expect-error +// @ts-expect-error -- the function works fine but somehow it does not satisfy the expected type export const createFrames: CreateFramesForRemix = function createFramesForRemix( options?: types.FramesOptions ) { diff --git a/packages/frames.js/src/remix/test.types.tsx b/packages/frames.js/src/remix/test.types.tsx index e167c5d37..106b34725 100644 --- a/packages/frames.js/src/remix/test.types.tsx +++ b/packages/frames.js/src/remix/test.types.tsx @@ -1,5 +1,6 @@ -import { ActionFunction, LoaderFunction } from '@remix-run/node'; -import { createFrames, types } from '.'; +/* eslint-disable @typescript-eslint/require-await -- we are checking compatibility with promises */ +import type { ActionFunction, LoaderFunction } from '@remix-run/node'; +import { createFrames, type types } from '.'; type Handler = LoaderFunction | ActionFunction; @@ -40,8 +41,29 @@ framesWithExplicitState(async ctx => { test: boolean; }; ctx satisfies { - message?: any; - pressedButton?: any; + message?: unknown; + pressedButton?: unknown; + request: Request; + } + + return { + image: 'http://test.png' + }; +}) satisfies Handler; + +const framesWithExplicitStateNoPromises = createFrames<{ + test: boolean; +}>({}); +framesWithExplicitStateNoPromises(ctx => { + ctx.state satisfies { + test: boolean; + }; + ctx.initialState satisfies { + test: boolean; + }; + ctx satisfies { + message?: unknown; + pressedButton?: unknown; request: Request; } diff --git a/packages/frames.js/src/types.ts b/packages/frames.js/src/types.ts index de4c10566..ca9c4da3d 100644 --- a/packages/frames.js/src/types.ts +++ b/packages/frames.js/src/types.ts @@ -11,7 +11,7 @@ export type Frame = { /** A valid frame version string. The string must be a release date (e.g. 2020-01-01 ) or vNext. Apps must ignore versions they do not understand. Currently, the only valid version is vNext. */ version: FrameVersion; /** A 256-byte string which contains a valid URL to send the Signature Packet to. If this prop is not present, apps must POST to the frame URL. */ - postUrl: string; + postUrl?: string; /** A page may contain 0 to 4 buttons. If more than 1 button is present, the idx values must be in sequence starting from 1 (e.g. 1, 2 3). If a broken sequence is present (e.g 1, 2, 4), apps must not render the frame and instead render an OG embed. */ buttons?: FrameButtonsType; /** An image which should have an aspect ratio of 1.91:1 or 1:1 */ @@ -34,6 +34,7 @@ type FrameOptionalStringKeys = | "fc:frame:image:aspect_ratio" | "fc:frame:input:text" | "fc:frame:state" + | "fc:frame:post_url" | keyof OpenFramesProperties; type FrameOptionalActionButtonTypeKeys = `fc:frame:button:${1 | 2 | 3 | 4}:action`; @@ -56,7 +57,6 @@ type MapFrameOptionalKeyToValueType = type FrameRequiredProperties = { "fc:frame": FrameVersion; "fc:frame:image": string; - "fc:frame:post_url": string; }; export type OpenFramesProperties = { @@ -90,8 +90,6 @@ export interface FrameButtonLink { action: "link"; /** required for action type 'link' */ target: string; - post_url?: undefined; - /** A 256-byte string which is label of the button */ label: string; } @@ -108,8 +106,6 @@ export interface FrameButtonMint { action: "mint"; /** The target property MUST be a valid [CAIP-10](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md) address, plus an optional token_id . */ target: string; - post_url?: undefined; - /** A 256-byte string which is label of the button */ label: string; } @@ -124,7 +120,6 @@ export interface FrameButtonPost { * POST the packet to fc:frame:post_url if target was not present. */ target?: string; - post_url?: undefined; /** A 256-byte string which is label of the button */ label: string; } @@ -133,7 +128,6 @@ export type FrameButtonPostRedirect = FrameButtonPost; export type FrameButton = | FrameButtonPost | FrameButtonLink - | FrameButtonPostRedirect | FrameButtonMint | FrameButtonTx; @@ -224,7 +218,7 @@ export type FrameActionPayload = { /** an optional transaction id property. For Ethereum, this must be the transaction hash. For other chains, this is not yet specified. */ transactionId?: string; }; - /** Open Frames spec: the identifier and version of the client protocol that sent the request e.g. farcaster@vNext */ + /** Open Frames spec: the identifier and version of the client protocol that sent the request e.g. farcaster\@vNext */ clientProtocol?: string; }; diff --git a/packages/frames.js/src/utils.ts b/packages/frames.js/src/utils.ts index 74664a627..7fcca5258 100644 --- a/packages/frames.js/src/utils.ts +++ b/packages/frames.js/src/utils.ts @@ -1,4 +1,5 @@ -import { CastId, Message, MessageType, Protocol } from "./farcaster"; +import type { CastId } from "./farcaster"; +import { Message, MessageType, Protocol } from "./farcaster"; import type { FrameActionPayload, FrameButton, @@ -26,7 +27,7 @@ export function isFrameButtonMint( } export function bytesToHexString(bytes: Uint8Array): `0x${string}` { - return ("0x" + Buffer.from(bytes).toString("hex")) as `0x${string}`; + return `0x${Buffer.from(bytes).toString("hex")}`; } export function getByteLength(str: string): number { @@ -34,9 +35,13 @@ export function getByteLength(str: string): number { } export function hexStringToUint8Array(hexstring: string): Uint8Array { - return new Uint8Array( - hexstring.match(/.{1,2}/g)!.map((byte: string) => parseInt(byte, 16)) - ); + const matches = hexstring.match(/.{1,2}/g); + + if (!matches) { + throw new Error("Invalid hex string provided"); + } + + return new Uint8Array(matches.map((byte: string) => parseInt(byte, 16))); } export function normalizeCastId(castId: CastId): { @@ -55,14 +60,12 @@ export function normalizeCastId(castId: CastId): { export function getFrameMessageFromRequestBody( body: FrameActionPayload ): Message { - return Message.decode( - Buffer.from(body?.trustedData?.messageBytes ?? "", "hex") - ); + return Message.decode(Buffer.from(body.trustedData.messageBytes, "hex")); } /** * Validates whether the version param is valid - * @param version the version string to validate + * @param version - the version string to validate * @returns true if the provided version conforms to the Frames spec */ export function isValidVersion(version: string): boolean { @@ -104,7 +107,7 @@ export function getEnumKeyByEnumValue< } export function extractAddressFromJSONMessage( - message: any + message: unknown ): `0x${string}` | null { const { data } = Message.fromJSON(message); @@ -136,5 +139,8 @@ export function extractAddressFromJSONMessage( * For example for value 0x8d25687829d6b85d9e0020b8c89e3ca24de20a89 from API we get 0x8d25687829d6b85d9e0020b8c89e3ca24de20a8w== from Buffer.from(...).toString('base64'). * The values are the same if you compare them as Buffer.from(a).equals(Buffer.from(b)). */ - return message.data.verificationAddAddressBody.address; + // @TODO type message properly or handle the return type properly + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we know the data is there + return (message as Record).data.verificationAddAddressBody + .address as `0x${string}`; } diff --git a/packages/frames.js/src/validateFrameMessage.ts b/packages/frames.js/src/validateFrameMessage.ts index ec073595b..6e4d9a035 100644 --- a/packages/frames.js/src/validateFrameMessage.ts +++ b/packages/frames.js/src/validateFrameMessage.ts @@ -1,7 +1,27 @@ import type { FrameActionPayload, HubHttpUrlOptions } from "./types"; import { hexStringToUint8Array } from "./utils"; import { DEFAULT_HUB_API_KEY, DEFAULT_HUB_API_URL } from "./default"; -import { type FrameActionMessage, Message } from "./farcaster"; +import type { FrameActionMessage } from "./farcaster"; +import { Message } from "./farcaster"; + +type ValidateMessageJson = { + valid: boolean; + message: Message; +}; + +function isValidateMessageJson(value: unknown): value is ValidateMessageJson { + if (typeof value !== "object" || !value) { + return false; + } + + const validateMessageJson = value as ValidateMessageJson; + + return ( + typeof validateMessageJson.valid === "boolean" && + validateMessageJson.valid && + typeof validateMessageJson.message === "string" + ); +} /** * @returns a Promise that resolves with whether the message signature is valid, by querying a Farcaster hub, as well as the message itself @@ -20,6 +40,7 @@ export async function validateFrameMessage( isValid: boolean; message: FrameActionMessage | undefined; }> { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- just in case if (!body) { throw new Error( "Tried to call validateFrameMessage with no frame action payload. You may be calling it incorrectly on the homeframe" @@ -40,7 +61,14 @@ export async function validateFrameMessage( } ); - const validateMessageJson = await validateMessageResponse.json(); + const validateMessageJson: unknown = await validateMessageResponse.json(); + + if (!isValidateMessageJson(validateMessageJson)) { + return { + isValid: false, + message: undefined, + }; + } if (validateMessageJson.valid) { return { @@ -49,10 +77,10 @@ export async function validateFrameMessage( validateMessageJson.message ) as FrameActionMessage, }; - } else { - return { - isValid: false, - message: undefined, - }; } + + return { + isValid: false, + message: undefined, + }; } diff --git a/packages/frames.js/src/xmtp/index.ts b/packages/frames.js/src/xmtp/index.ts index c776bcf57..54358e365 100644 --- a/packages/frames.js/src/xmtp/index.ts +++ b/packages/frames.js/src/xmtp/index.ts @@ -1,8 +1,6 @@ -import { - validateFramesPost, - XmtpOpenFramesRequest, -} from "@xmtp/frames-validator"; -import { frames } from "@xmtp/proto"; +import type { XmtpOpenFramesRequest } from "@xmtp/frames-validator"; +import { validateFramesPost } from "@xmtp/frames-validator"; +import type { frames } from "@xmtp/proto"; export type XmtpFrameMessageReturnType = frames.FrameActionBody & { verifiedWalletAddress: string; @@ -25,7 +23,7 @@ export async function getXmtpFrameMessage( ): Promise { const { actionBody, verifiedWalletAddress } = await validateFramesPost({ ...frameActionPayload, - clientProtocol: frameActionPayload.clientProtocol as `xmtp@${string}`, + clientProtocol: frameActionPayload.clientProtocol, }); return { diff --git a/packages/render/package.json b/packages/render/package.json index 37ec95422..20fcad06a 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -79,7 +79,8 @@ "peerDependencies": { "next": "^14.1.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "viem": "^2.7.8" }, "dependencies": { "@farcaster/core": "^0.14.7", diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx index 579fe03a3..63b3e64bd 100644 --- a/packages/render/src/farcaster/frames.tsx +++ b/packages/render/src/farcaster/frames.tsx @@ -1,19 +1,19 @@ -import { +import type { CastId, + FrameActionMessage +} from "@farcaster/core"; +import { NobleEd25519Signer, makeFrameAction, FarcasterNetwork, Message, - FrameActionBody, - FrameActionMessage, + FrameActionBody } from "@farcaster/core"; -import { FrameButton } from "frames.js"; -import { FrameActionBodyPayload, FrameContext } from "../types.js"; -import { FarcasterSignerState } from "./signers.js"; import { hexToBytes } from "viem"; +import type { FrameButton } from "frames.js"; +import type { FrameContext } from "../types"; +import type { FarcasterSignerState } from "./signers"; -export interface FarcasterFrameActionBodyPayload - extends FrameActionBodyPayload {} export type FarcasterFrameContext = { /** Connected address of user, only sent with transaction data request */ @@ -80,16 +80,16 @@ export const signFrameAction = async ({ } const searchParams = new URLSearchParams({ - postType: transactionId ? "post" : frameButton?.action || "post", + postType: transactionId ? "post" : frameButton.action, postUrl: target ?? "", }); return { - searchParams: searchParams, + searchParams, body: { untrustedData: { fid: signer.fid, - url: url, + url, messageHash: `0x${Buffer.from(message.hash).toString("hex")}`, timestamp: message.data.timestamp, network: 1, @@ -169,5 +169,5 @@ export async function createFrameActionMessageWithSignerKey( Message.encode(message._unsafeUnwrap()).finish() ).toString("hex"); - return { message: message.unwrapOr(null), trustedBytes: trustedBytes }; + return { message: message.unwrapOr(null), trustedBytes }; } diff --git a/packages/render/src/farcaster/index.ts b/packages/render/src/farcaster/index.ts index 69a6804dd..a20359e0f 100644 --- a/packages/render/src/farcaster/index.ts +++ b/packages/render/src/farcaster/index.ts @@ -1,2 +1,2 @@ -export * from "./frames.js"; -export * from "./signers.js"; +export * from "./frames"; +export * from "./signers"; diff --git a/packages/render/src/farcaster/signers.tsx b/packages/render/src/farcaster/signers.tsx index c00df6c14..8d76727c8 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,11 +1,8 @@ -import { SignerStateInstance } from "../types.js"; -import { FarcasterFrameActionBodyPayload } from "./frames.js"; +import type { SignerStateInstance } from ".."; -export interface FarcasterSignerState - extends SignerStateInstance< - FarcasterSigner | null, - FarcasterFrameActionBodyPayload - > {} +export type FarcasterSignerState = SignerStateInstance< + FarcasterSigner | null + > export interface FarcasterSigner { /* the Farcaster signer private key */ diff --git a/packages/render/src/frame-ui.tsx b/packages/render/src/frame-ui.tsx index 181270899..90c5b72eb 100644 --- a/packages/render/src/frame-ui.tsx +++ b/packages/render/src/frame-ui.tsx @@ -1,6 +1,7 @@ -import { FrameTheme, FrameState } from "./types.js"; -import React, { ImgHTMLAttributes, useEffect } from "react"; -import { FrameButton } from "frames.js"; +import type { ImgHTMLAttributes } from "react"; +import React, { useEffect, useState } from "react"; +import type { FrameButton } from "frames.js"; +import type { FrameTheme, FrameState } from "./types"; export const defaultTheme: Required = { buttonBg: "#fff", @@ -11,7 +12,7 @@ export const defaultTheme: Required = { bg: "#efefef", }; -const getThemeWithDefaults = (theme: FrameTheme) => { +const getThemeWithDefaults = (theme: FrameTheme): FrameTheme => { return { ...defaultTheme, ...theme, @@ -25,8 +26,12 @@ export type FrameUIProps = { }; /** A UI component only, that should be easy for any app to integrate */ -export function FrameUI({ frameState, theme, FrameImage }: FrameUIProps) { - const [isImageLoading, setIsImageLoading] = React.useState(true); +export function FrameUI({ + frameState, + theme, + FrameImage, +}: FrameUIProps): React.JSX.Element | null { + const [isImageLoading, setIsImageLoading] = useState(true); const isLoading = !!frameState.isLoading || isImageLoading; @@ -53,7 +58,7 @@ export function FrameUI({ frameState, theme, FrameImage }: FrameUIProps) { { setIsImageLoading(false); }} - onError={() => setIsImageLoading(false)} + onError={() => { + setIsImageLoading(false); + }} /> - {frameState.frame.inputText && ( + {frameState.frame.inputText ? ( frameState.setInputText(e.target.value)} + onChange={(e) => { + frameState.setInputText(e.target.value); + }} /> - )} + ) : null}
frameState.onButtonPress(frameButton, index)} + onClick={() => { + Promise.resolve(frameState.onButtonPress(frameButton, index)).catch((e: unknown) => { + // eslint-disable-next-line no-console -- provide feedback to the user + console.error(e); + }); + }} + // eslint-disable-next-line react/no-array-index-key -- this is fine key={index} > {frameButton.action === "mint" ? `⬗ ` : ""} @@ -124,7 +139,7 @@ export function FrameUI({ frameState, theme, FrameImage }: FrameUIProps) { height="12" fill="currentColor" > - + ) : ( "" diff --git a/packages/render/src/index.tsx b/packages/render/src/index.tsx index 336dd1abe..2b8da4fe0 100644 --- a/packages/render/src/index.tsx +++ b/packages/render/src/index.tsx @@ -1,4 +1,4 @@ -import { FrameContext } from "./types.js"; +import type { FrameContext } from "./types"; export const fallbackFrameContext: FrameContext = { castId: { @@ -10,6 +10,6 @@ export const fallbackFrameContext: FrameContext = { export * from "./frame-ui.js"; export * from "./types.js"; -export * from "./farcaster/index.js"; +export * from "./farcaster"; /** don't export use-frame from here, as it's a client component */ diff --git a/packages/render/src/next/FrameImage.tsx b/packages/render/src/next/FrameImage.tsx index c31c3a364..47d46a25a 100644 --- a/packages/render/src/next/FrameImage.tsx +++ b/packages/render/src/next/FrameImage.tsx @@ -1,9 +1,10 @@ import Image from "next/image"; -import React, { ImgHTMLAttributes } from "react"; +import type { ImgHTMLAttributes } from "react"; +import React from "react"; export function FrameImageNext( props: ImgHTMLAttributes & { src: string } -) { +): React.JSX.Element { return ( { return NextResponse.json({ frame, errors }); } catch (err) { + // eslint-disable-next-line no-console -- provide feedback to the developer console.error(err); return NextResponse.json({ message: err }, { status: 500 }); } diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index d9d3f034d..6f2f61fdb 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -1,14 +1,19 @@ +import type { FrameActionPayload} from "frames.js"; import { getFrame } from "frames.js"; -import { NextRequest } from "next/server"; +import type { NextRequest } from "next/server"; /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ -export async function POST(req: NextRequest) { - const body = await req.json(); +export async function POST(req: NextRequest): Promise { + const body = await req.json() as FrameActionPayload; const isPostRedirect = req.nextUrl.searchParams.get("postType") === "post_redirect"; const isTransactionRequest = req.nextUrl.searchParams.get("postType") === "tx"; - const postUrl = req.nextUrl.searchParams.get("postUrl")!; + const postUrl = req.nextUrl.searchParams.get("postUrl"); + + if (!postUrl) { + return Response.error(); + } try { const r = await fetch(postUrl, { @@ -31,7 +36,7 @@ export async function POST(req: NextRequest) { } if (isTransactionRequest) { - const transaction = await r.json(); + const transaction = await r.json() as JSON; return Response.json(transaction); } @@ -44,6 +49,7 @@ export async function POST(req: NextRequest) { return Response.json({ frame, errors }); } catch (err) { + // eslint-disable-next-line no-console -- provide feedback to the user console.error(err); return Response.error(); } diff --git a/packages/render/src/next/index.tsx b/packages/render/src/next/index.tsx index 6152bc031..373679f71 100644 --- a/packages/render/src/next/index.tsx +++ b/packages/render/src/next/index.tsx @@ -1,3 +1,3 @@ -export * from "./GET.js"; -export * from "./POST.js"; -export * from "./FrameImage.js"; +export * from "./GET"; +export * from "./POST"; +export * from "./FrameImage"; diff --git a/packages/render/src/types.ts b/packages/render/src/types.ts index ca68e060e..8992e6430 100644 --- a/packages/render/src/types.ts +++ b/packages/render/src/types.ts @@ -1,8 +1,8 @@ import type { Frame, FrameButton, TransactionTargetResponse } from "frames.js"; -import { FarcasterFrameContext } from "./farcaster/index.js"; +import type { FarcasterFrameContext } from "./farcaster/frames"; export type OnTransactionFunc = ( - t: onTransactionArgs + t: OnTransactionArgs ) => Promise<`0x${string}` | null>; export type UseFrameReturn< @@ -22,7 +22,7 @@ export type UseFrameReturn< /** the initial frame. if not specified will fetch it from the url prop */ frame?: Frame; /** a function to handle mint buttons */ - onMint?: (t: onMintArgs) => void; + onMint?: (t: OnMintArgs) => void; /** a function to handle transaction buttons, returns the transaction hash or null */ onTransaction?: OnTransactionFunc; /** the context of this frame, used for generating Frame Action payloads */ @@ -65,7 +65,6 @@ export interface SignerStateInstance< export type FrameRequest = | { method: "GET"; - request: {}; url: string; } | { @@ -97,10 +96,10 @@ export type FrameStackError = FrameStackBase & { requestError: unknown; }; -export type FramesStack = Array; +export type FramesStack = (FrameStackSuccess | FrameStackError)[]; export type FrameState = { - fetchFrame: (request: FrameRequest) => void; + fetchFrame: (request: FrameRequest) => void | Promise; clearFrameStack: () => void; /** The frame at the top of the stack (at index 0) */ frame: Frame | null; @@ -110,21 +109,24 @@ export type FrameState = { isLoading?: null | FrameStackPending; inputText: string; setInputText: (s: string) => void; - onButtonPress: (frameButton: FrameButton, index: number) => void; + onButtonPress: ( + frameButton: FrameButton, + index: number + ) => void | Promise; /** Whether the frame at the top of the stack has any frame validation errors. Undefined when the frame is not loaded or set */ isFrameValid: boolean | undefined; frameValidationErrors: Record | undefined | null; - error: null | unknown; + error: unknown; homeframeUrl: string | null; }; -export type onMintArgs = { +export type OnMintArgs = { target: string; frameButton: FrameButton; frame: Frame; }; -export type onTransactionArgs = { +export type OnTransactionArgs = { transactionData: TransactionTargetResponse; frameButton: FrameButton; frame: Frame; @@ -141,6 +143,6 @@ export const themeParams = [ export type FrameTheme = Partial>; -export interface FrameActionBodyPayload {} +export type FrameActionBodyPayload = Record; export type FrameContext = FarcasterFrameContext; diff --git a/packages/render/src/use-frame.tsx b/packages/render/src/use-frame.tsx index 1606e69e4..a5bfb0d1a 100644 --- a/packages/render/src/use-frame.tsx +++ b/packages/render/src/use-frame.tsx @@ -1,9 +1,19 @@ +/* eslint-disable @typescript-eslint/require-await -- we expect async functions */ +/* eslint-disable no-console -- provide feedback */ +/* eslint-disable no-alert -- provide feedback */ "use client"; -import { useEffect, useState } from "react"; -import { +import { useEffect, useMemo, useRef, useState } from "react"; +import type { + FrameActionPayload, + FrameButton, + TransactionTargetResponse, + getFrame, +} from "frames.js"; +import { getFarcasterTime } from "@farcaster/core"; +import type { FrameState, - onMintArgs, + OnMintArgs, FrameContext, SignerStateInstance, FrameActionBodyPayload, @@ -11,14 +21,11 @@ import { FrameStackPending, FrameRequest, UseFrameReturn, - onTransactionArgs, -} from "./types.js"; -import type { FrameButton, TransactionTargetResponse } from "frames.js"; -import { getFrame } from "frames.js"; -import { getFarcasterTime } from "@farcaster/core"; + OnTransactionArgs, +} from "./types"; -function onMintFallback({ target }: onMintArgs) { - window.alert("Mint requested: " + target!); +function onMintFallback({ target }: OnMintArgs): void { + window.alert(`Mint requested: ${target}`); } export const unsignedFrameAction: SignerStateInstance["signFrameAction"] = @@ -32,18 +39,18 @@ export const unsignedFrameAction: SignerStateInstance["signFrameAction"] = url, }) => { const searchParams = new URLSearchParams({ - postType: frameButton?.action || "post", + postType: frameButton.action, postUrl: target ?? "", }); return { - searchParams: searchParams, + searchParams, body: { untrustedData: { - url: url, + url, timestamp: getFarcasterTime()._unsafeUnwrap(), network: 1, - buttonIndex: buttonIndex, + buttonIndex, castId: { fid: frameContext.castId.fid, hash: frameContext.castId.hash, @@ -58,7 +65,10 @@ export const unsignedFrameAction: SignerStateInstance["signFrameAction"] = }, }; }; -async function onTransactionFallback({ transactionData }: onTransactionArgs) { + +async function onTransactionFallback({ + transactionData, +}: OnTransactionArgs): Promise { window.alert( `Requesting a transaction on chain with ID ${transactionData.chainId} with the following params: ${JSON.stringify(transactionData.params, null, 2)}` ); @@ -75,7 +85,7 @@ export const fallbackFrameContext: FrameContext = { export function useFrame< T = object, - B extends FrameActionBodyPayload = FrameActionBodyPayload, + B extends FrameActionBodyPayload = FrameActionPayload, >({ homeframeUrl, frameContext, @@ -91,14 +101,18 @@ export function useFrame< extraButtonRequestPayload, }: UseFrameReturn): FrameState { const [inputText, setInputText] = useState(""); - const initialFrame = frame ? { frame: frame, errors: null } : undefined; + const initialFrame = useMemo(() => { + if (frame) { + return { frame, errors: null }; + } + return undefined; + }, [frame]); const [framesStack, setFramesStack] = useState( initialFrame ? [ { method: "GET", - request: {}, responseStatus: 200, timestamp: new Date(), url: homeframeUrl ?? "", @@ -115,29 +129,28 @@ export function useFrame< // prevent flash of empty if will shortly set this in first rerender homeframeUrl && !initialFrame ? { - request: {}, method: "GET" as const, timestamp: new Date(), - url: homeframeUrl ?? "", + url: homeframeUrl, } : null ); - async function fetchFrame({ method, url, request }: FrameRequest) { - if (method === "GET") { + async function fetchFrame(frameRequest: FrameRequest): Promise { + if (frameRequest.method === "GET") { const tstart = new Date(); const frameStackBase = { request: {}, method: "GET" as const, timestamp: tstart, - url: url ?? "", + url: frameRequest.url, }; setIsLoading(frameStackBase); - let requestError: unknown | null = null; + let requestError: unknown = null; let newFrame: ReturnType | null = null; - const searchParams = new URLSearchParams({ url }); + const searchParams = new URLSearchParams({ url: frameRequest.url }); const proxiedUrl = `${frameGetProxy}?${searchParams.toString()}`; let stackItem: FramesStack[number]; @@ -151,7 +164,9 @@ export function useFrame< ...frameStackBase, frame: newFrame.frame, frameValidationErrors: newFrame.errors, - speed: +((tend.getTime() - tstart.getTime()) / 1000).toFixed(2), + speed: Number( + ((tend.getTime() - tstart.getTime()) / 1000).toFixed(2) + ), responseStatus: response.status, isValid: Object.keys(newFrame.errors ?? {}).length === 0, }; @@ -160,10 +175,12 @@ export function useFrame< stackItem = { ...frameStackBase, - url: url ?? "", + url: frameRequest.url, responseStatus: response?.status ?? 500, requestError, - speed: +((tend.getTime() - tstart.getTime()) / 1000).toFixed(2), + speed: Number( + ((tend.getTime() - tstart.getTime()) / 1000).toFixed(2) + ), }; requestError = err; @@ -174,15 +191,15 @@ export function useFrame< const frameStackBase = { method: "POST" as const, request: { - searchParams: request.searchParams, - body: request.body, + searchParams: frameRequest.request.searchParams, + body: frameRequest.request.body, }, timestamp: tstart, - url: url, + url: frameRequest.url, }; setIsLoading(frameStackBase); let stackItem: FramesStack[number] | undefined; - const proxiedUrl = `${frameActionProxy}?${request.searchParams.toString()}`; + const proxiedUrl = `${frameActionProxy}?${frameRequest.request.searchParams.toString()}`; let response; try { @@ -193,7 +210,7 @@ export function useFrame< }, body: JSON.stringify({ ...extraButtonRequestPayload, - ...request.body, + ...frameRequest.request.body, }), }); const dataRes = (await response.json()) as @@ -204,16 +221,16 @@ export function useFrame< if ("location" in dataRes) { const location = dataRes.location; - if ( - window.confirm("You are about to be redirected to " + location!) - ) { - window.open(location!, "_blank")?.focus(); + if (window.confirm(`You are about to be redirected to ${location}`)) { + window.open(location, "_blank")?.focus(); } } else { stackItem = { ...frameStackBase, responseStatus: response.status, - speed: +((tend.getTime() - tstart.getTime()) / 1000).toFixed(2), + speed: Number( + ((tend.getTime() - tstart.getTime()) / 1000).toFixed(2) + ), frame: dataRes.frame, frameValidationErrors: dataRes.errors, isValid: Object.keys(dataRes.errors ?? {}).length === 0, @@ -225,7 +242,9 @@ export function useFrame< ...frameStackBase, responseStatus: response?.status ?? 500, requestError: err, - speed: +((tend.getTime() - tstart.getTime()) / 1000).toFixed(2), + speed: Number( + ((tend.getTime() - tstart.getTime()) / 1000).toFixed(2) + ), }; console.error(err); @@ -236,46 +255,60 @@ export function useFrame< setIsLoading(null); } + const fetchFrameRef = useRef(fetchFrame); + fetchFrameRef.current = fetchFrame; + // Load initial frame if not defined useEffect(() => { if (!initialFrame && homeframeUrl) { - fetchFrame({ - url: homeframeUrl, - method: "GET", - request: {}, - }); + fetchFrameRef + .current({ + url: homeframeUrl, + method: "GET", + }) + .catch((e) => { + console.error(e); + }); } }, [initialFrame, homeframeUrl]); - function getCurrentFrame() { - const [frame] = framesStack; + function getCurrentFrame(): ReturnType["frame"] | null { + const [latestFrame] = framesStack; - return frame && "frame" in frame ? frame.frame : null; + return latestFrame && "frame" in latestFrame ? latestFrame.frame : null; } - function isCurrentFrameValid() { - const [frame] = framesStack; + function isCurrentFrameValid(): boolean | undefined { + const [latestFrame] = framesStack; - return frame && "frameValidationErrors" in frame - ? Object.keys(frame.frameValidationErrors ?? {}).length === 0 + return latestFrame && "frameValidationErrors" in latestFrame + ? Object.keys(latestFrame.frameValidationErrors ?? {}).length === 0 : undefined; } - function getCurrentFrameValidationErrors() { - const [frame] = framesStack; + function getCurrentFrameValidationErrors(): + | Record + | null + | undefined { + const [latestFrame] = framesStack; - return frame && "frameValidationErrors" in frame - ? frame.frameValidationErrors + return latestFrame && "frameValidationErrors" in latestFrame + ? latestFrame.frameValidationErrors : null; } - function getCurrentFrameRequestError() { - const [frame] = framesStack; + function getCurrentFrameRequestError(): unknown { + const [latestFrame] = framesStack; - return frame && "requestError" in frame ? frame.requestError : null; + return latestFrame && "requestError" in latestFrame + ? latestFrame.requestError + : null; } - const onButtonPress = async (frameButton: FrameButton, index: number) => { + async function onButtonPress( + frameButton: FrameButton, + index: number + ): Promise { const currentFrame = getCurrentFrame(); if (!currentFrame) { @@ -288,74 +321,101 @@ export function useFrame< // don't continue, let the app handle return; } - const target = frameButton.target ?? currentFrame.postUrl ?? homeframeUrl; - if (frameButton.action === "link") { - if (window.confirm("You are about to be redirected to " + target!)) { - parent.window.open(target!, "_blank"); + + switch (frameButton.action) { + case "link": { + if ( + window.confirm( + `You are about to be redirected to ${frameButton.target}` + ) + ) { + parent.window.open(frameButton.target, "_blank"); + } + break; } - } else if (frameButton.action === "mint") { - onMint({ frameButton, target, frame: currentFrame }); - } else if (frameButton.action === "tx") { - const transactionData = await onTransactionRequest({ - frameButton: frameButton, - target: target, - buttonIndex: index + 1, - postInputText: - currentFrame.inputText !== undefined ? inputText : undefined, - state: currentFrame.state, - }); - if (transactionData) { - const transactionId = await onTransaction({ + case "mint": { + onMint({ + frameButton, + target: frameButton.target, frame: currentFrame, - frameButton: frameButton, - transactionData, }); - if (transactionId) { + break; + } + case "tx": { + const transactionData = await onTransactionRequest({ + frameButton, + target: frameButton.target, + buttonIndex: index + 1, + postInputText: + currentFrame.inputText !== undefined ? inputText : undefined, + state: currentFrame.state, + }); + if (transactionData) { + const transactionId = await onTransaction({ + frame: currentFrame, + frameButton, + transactionData, + }); + + if (transactionId) { + await onPostButton({ + frameButton, + target: + frameButton.post_url || + currentFrame.postUrl || + frameButton.target, // transaction_ids must be posted to post_url or button post_url + buttonIndex: index + 1, + postInputText: + currentFrame.inputText !== undefined ? inputText : undefined, + state: currentFrame.state, + transactionId, + }); + } + } + break; + } + case "post": + case "post_redirect": { + try { + const target = + frameButton.target || currentFrame.postUrl || homeframeUrl; + + if (!target) { + throw new Error("missing target"); + } + await onPostButton({ - frameButton: frameButton, - target: frameButton.post_url || currentFrame.postUrl, // transaction_ids must be posted to post_url or button post_url + frameButton, + /** https://docs.farcaster.xyz/reference/frames/spec#handling-clicks + + POST the packet to fc:frame:button:$idx:action:target if present + POST the packet to fc:frame:post_url if target was not present. + POST the packet to or the frame's embed URL if neither target nor action were present. + */ + target, + dangerousSkipSigning, buttonIndex: index + 1, postInputText: currentFrame.inputText !== undefined ? inputText : undefined, state: currentFrame.state, - transactionId, }); + setInputText(""); + } catch (err) { + alert("error: check the console"); + console.error(err); } + break; } - } else if ( - frameButton.action === "post" || - frameButton.action === "post_redirect" - ) { - try { - await onPostButton({ - frameButton: frameButton, - target: target, - dangerousSkipSigning: dangerousSkipSigning, - - buttonIndex: index + 1, - /** https://docs.farcaster.xyz/reference/frames/spec#handling-clicks - - POST the packet to fc:frame:button:$idx:action:target if present - POST the packet to fc:frame:post_url if target was not present. - POST the packet to or the frame's embed URL if neither target nor action were present. - */ - postInputText: - currentFrame.inputText !== undefined ? inputText : undefined, - state: currentFrame.state, - }); - setInputText(""); - } catch (err) { - alert("error: check the console"); - console.error(err); - } + default: + throw new Error("Unrecognized frame button action"); } - }; + } - const onPostButton = async ({ + async function onPostButton({ buttonIndex, postInputText, frameButton, - dangerousSkipSigning, + dangerousSkipSigning: isDangerousSkipSigning, target, state, transactionId, @@ -368,14 +428,14 @@ export function useFrame< transactionId?: `0x${string}`; target: string; - }) => { + }): Promise { const currentFrame = getCurrentFrame(); - if (!dangerousSkipSigning && !signerState.hasSigner) { + if (!isDangerousSkipSigning && !signerState.hasSigner) { console.error("frames.js: missing required auth state"); return; } - if (!currentFrame || !currentFrame || !homeframeUrl || !frameButton) { + if (!currentFrame || !homeframeUrl) { console.error("frames.js: missing required value for post"); return; } @@ -393,7 +453,7 @@ export function useFrame< state, transactionId, }; - const { searchParams, body } = dangerousSkipSigning + const { searchParams, body } = isDangerousSkipSigning ? await unsignedFrameAction(frameSignatureContext) : await signerState.signFrameAction(frameSignatureContext); @@ -406,21 +466,17 @@ export function useFrame< body, }, }); - }; + } - const onTransactionRequest = async ({ - buttonIndex, - postInputText, - frameButton, - target, - state, + async function onTransactionRequest({ + buttonIndex, postInputText, frameButton, target, state, }: { frameButton: FrameButton; buttonIndex: number; postInputText: string | undefined; state?: string; target: string; - }) => { + }): Promise { // Send post request to get calldata const currentFrame = getCurrentFrame(); @@ -428,7 +484,7 @@ export function useFrame< console.error("frames.js: missing required auth state"); return; } - if (!currentFrame || !currentFrame || !homeframeUrl || !frameButton) { + if (!currentFrame || !homeframeUrl) { console.error("frames.js: missing required value for post"); return; } @@ -442,8 +498,8 @@ export function useFrame< }, url: homeframeUrl, target, - frameButton: frameButton, - buttonIndex: buttonIndex, + frameButton, + buttonIndex, state, }); searchParams.append("postType", "tx"); @@ -461,25 +517,26 @@ export function useFrame< ...body, }), }); - const transactionResponse = - (await response.json()) as TransactionTargetResponse; + const transactionResponse = (await response.json()) as TransactionTargetResponse; return transactionResponse; } catch { throw new Error( `frames.js: Could not fetch transaction data from "${searchParams.get("postUrl")}"` ); } - }; + } return { - isLoading: isLoading, + isLoading, inputText, setInputText, - clearFrameStack: () => setFramesStack([]), + clearFrameStack: () => { + setFramesStack([]); + }, onButtonPress, fetchFrame, homeframeUrl, - framesStack: framesStack, + framesStack, frame: getCurrentFrame() ?? null, isFrameValid: isCurrentFrameValid(), frameValidationErrors: getCurrentFrameValidationErrors(), diff --git a/turbo.json b/turbo.json index 240e4b961..54a90ce2f 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,8 @@ "outputs": [".next/**", "**/dist/**", "!.next/cache/**"] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": ["^lint"], + "cache": false }, "dev": { "cache": false, diff --git a/yarn.lock b/yarn.lock index c8b9c8e47..9bc1e1c61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13456,7 +13456,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13563,7 +13572,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15018,7 +15034,7 @@ wrangler@3.39.0, wrangler@^3.39.0: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15036,6 +15052,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"