Skip to content

Commit f4d5f4f

Browse files
fix: allow to explicitly set state using generics (#262)
1 parent 92987b2 commit f4d5f4f

20 files changed

+460
-12
lines changed

.changeset/eight-hounds-remember.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"frames.js": patch
3+
---
4+
5+
fix: allow to explicitly set state using generics

.github/workflows/github-actions.yml

+4-1
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ jobs:
6969
- name: Install dependencies
7070
run: yarn --frozen-lockfile
7171

72-
- name: Typecheck
72+
- name: Build
7373
run: yarn build:ci
7474

75+
- name: Typecheck
76+
run: yarn typecheck
77+
7578
test:
7679
needs: [lint, typecheck]
7780
runs-on: ubuntu-latest

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"lint": "turbo lint --filter=!template-*",
1212
"test:ci": "jest --ci",
1313
"test": "cd ./packages/frames.js && npm run test:watch",
14+
"typecheck": "turbo typecheck",
1415
"publish-packages": "yarn build lint && changeset version && changeset publish && git push --follow-tags origin main",
1516
"publish-canary": "turbo run build lint && cd ./packages/frames.js && yarn publish --tag canary && git push --follow-tags origin main",
1617
"format": "prettier --write \"**/*.{ts,tsx,md}\""
@@ -41,4 +42,4 @@
4142
"templates/*"
4243
],
4344
"version": "0.3.0-canary.0"
44-
}
45+
}

packages/frames.js/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"build": "NODE_OPTIONS='--max-old-space-size=16384' tsup",
1818
"dev": "npm run build -- --watch",
1919
"test:watch": "jest --watch",
20-
"update:proto": "curl https://raw.githubusercontent.com/farcasterxyz/hub-monorepo/main/packages/core/src/protobufs/generated/message.ts -o src/farcaster/generated/message.ts"
20+
"update:proto": "curl https://raw.githubusercontent.com/farcasterxyz/hub-monorepo/main/packages/core/src/protobufs/generated/message.ts -o src/farcaster/generated/message.ts",
21+
"typecheck": "tsc --noEmit"
2122
},
2223
"repository": {
2324
"type": "git",
@@ -257,6 +258,7 @@
257258
"license": "MIT",
258259
"peerDependencies": {
259260
"@cloudflare/workers-types": "^4.20240320.1",
261+
"@types/express": "^4.17.21",
260262
"@xmtp/frames-validator": "^0.5.2",
261263
"next": "^14.1.0",
262264
"react": "^18.2.0",

packages/frames.js/src/cloudflare-workers/index.test.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,32 @@ describe("cloudflare workers adapter", () => {
2424
expect(response.status).toBe(200);
2525
expect(response.headers.get("content-type")).toBe("text/html");
2626
});
27+
28+
it('works properly with state', async () => {
29+
type State = {
30+
test: boolean;
31+
};
32+
const frames = lib.createFrames<State>({
33+
initialState: {
34+
test: false,
35+
},
36+
});
37+
38+
const handler = frames(async (ctx) => {
39+
expect(ctx.state).toEqual({ test: false });
40+
41+
return {
42+
image: 'http://test.png',
43+
state: ctx.state satisfies State,
44+
};
45+
});
46+
47+
const request = new Request("http://localhost:3000");
48+
49+
// @ts-expect-error - expects fetcher property on request but it is not used by our lib
50+
const response = await handler(request, {}, {});
51+
52+
expect(response.status).toBe(200);
53+
expect(response.headers.get("content-type")).toBe("text/html");
54+
});
2755
});

packages/frames.js/src/cloudflare-workers/index.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ type DefaultMiddleware<TEnv> = [
4343
* });
4444
*
4545
* @example
46-
* // With custom type for Env
47-
* import { createFrames, Button } from 'frames.js/cloudflare-workers';
46+
* // With custom type for Env and state
47+
* import { createFrames, Button, type types } from 'frames.js/cloudflare-workers';
4848
*
4949
* type Env = {
50-
* secret: string;
50+
* secret: string;
5151
* };
5252
*
53-
* const frames = createFrames<Env>();
53+
* type State = { test: boolean };
54+
*
55+
* const frames = createFrames<State, Env>();
5456
* const fetch = frames(async (ctx) => {
5557
* return {
5658
* image: <span>{ctx.cf.env.secret}</span>,
@@ -67,11 +69,11 @@ type DefaultMiddleware<TEnv> = [
6769
* } satisfies ExportedHandler;
6870
*/
6971
export function createFrames<
72+
TState extends JsonValue | undefined = JsonValue | undefined,
7073
TEnv = unknown,
7174
TFramesMiddleware extends
7275
| FramesMiddleware<any, any>[]
7376
| undefined = undefined,
74-
TState extends JsonValue = JsonValue,
7577
>(
7678
options?: types.FramesOptions<TState, TFramesMiddleware>
7779
): FramesRequestHandlerFunction<
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ExecutionContext, Request as CfRequest, ExportedHandlerFetchHandler } from '@cloudflare/workers-types';
2+
import { createFrames, types } from '.';
3+
4+
const framesWithoutState = createFrames();
5+
framesWithoutState(async (ctx) => {
6+
ctx.initialState satisfies types.JsonValue | undefined;
7+
ctx.state satisfies types.JsonValue | undefined;
8+
9+
return {
10+
image: 'http://test.png',
11+
};
12+
}) satisfies ExportedHandlerFetchHandler;
13+
14+
const framesWithInferredState = createFrames({
15+
initialState: { test: true },
16+
});
17+
18+
framesWithInferredState(async (ctx) => {
19+
ctx.state satisfies { test: boolean; };
20+
21+
return {
22+
image: 'http://test.png',
23+
};
24+
}) satisfies ExportedHandlerFetchHandler;
25+
26+
const framesWithExplicitState = createFrames<{ test: boolean }>({});
27+
framesWithExplicitState(async (ctx) => {
28+
ctx.state satisfies { test: boolean };
29+
ctx satisfies { initialState?: {test: boolean}; message?: any, pressedButton?: any };
30+
ctx satisfies { cf: { env: unknown; ctx: ExecutionContext; req: CfRequest }}
31+
32+
return {
33+
image: 'http://test.png',
34+
};
35+
}) satisfies ExportedHandlerFetchHandler;
36+
37+
const framesWithExplicitStateAndEnv = createFrames<{ test: boolean }, { secret: string }>({});
38+
framesWithExplicitStateAndEnv(async (ctx) => {
39+
ctx.state satisfies { test: boolean };
40+
ctx satisfies { initialState?: { test: boolean }; message?: any, pressedButton?: any; request: Request; };
41+
ctx satisfies { cf: { env: { secret: string }; ctx: ExecutionContext; req: CfRequest }}
42+
43+
return {
44+
image: 'http://test.png',
45+
};
46+
}) satisfies ExportedHandlerFetchHandler<{ secret: string }>;

packages/frames.js/src/core/createFrames.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
} from "./types";
1212

1313
export function createFrames<
14-
TState extends JsonValue | undefined,
14+
TState extends JsonValue | undefined = JsonValue | undefined,
1515
TMiddlewares extends FramesMiddleware<any, any>[] | undefined = undefined,
1616
>({
1717
basePath = "/",
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createFrames, types } from '.';
2+
3+
type Handler = (req: Request) => Promise<Response>;
4+
5+
const framesWithoutState = createFrames();
6+
framesWithoutState(async (ctx) => {
7+
ctx.initialState satisfies types.JsonValue | undefined;
8+
ctx.state satisfies types.JsonValue | undefined;
9+
10+
return {
11+
image: 'http://test.png',
12+
};
13+
}) satisfies Handler;
14+
15+
const framesWithInferredState = createFrames({
16+
initialState: { test: true },
17+
});
18+
19+
framesWithInferredState(async (ctx) => {
20+
ctx.state satisfies { test: boolean };
21+
22+
return {
23+
image: 'http://test.png',
24+
};
25+
}) satisfies Handler;
26+
27+
const framesWithExplicitState = createFrames<{ test: boolean }>({});
28+
framesWithExplicitState(async (ctx) => {
29+
ctx.state satisfies { test: boolean };
30+
ctx satisfies { initialState?: {test: boolean}; message?: any, pressedButton?: any };
31+
32+
return {
33+
image: 'http://test.png',
34+
};
35+
}) satisfies Handler;
36+
37+
const framesWithExplicitStateAndEnv = createFrames<{ test: boolean }>({});
38+
framesWithExplicitStateAndEnv(async (ctx) => {
39+
ctx.state satisfies { test: boolean };
40+
ctx satisfies { initialState?: { test: boolean }; message?: any, pressedButton?: any; request: Request; };
41+
42+
43+
return {
44+
image: 'http://test.png',
45+
};
46+
}) satisfies Handler;

packages/frames.js/src/core/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,8 @@ export type CreateFramesFunctionDefinition<
212212
| undefined,
213213
TRequestHandlerFunction extends Function,
214214
> = <
215+
TState extends JsonValue | undefined = JsonValue | undefined,
215216
TFrameMiddleware extends FramesMiddleware<any, any>[] | undefined = undefined,
216-
TState extends JsonValue = JsonValue,
217217
>(
218218
options?: FramesOptions<TState, TFrameMiddleware>
219219
) => FramesRequestHandlerFunction<

packages/frames.js/src/express/index.test.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,32 @@ describe("express adapter", () => {
101101
);
102102
});
103103
});
104+
105+
it('works properly with state', async () => {
106+
type State = {
107+
test: boolean;
108+
};
109+
const app = express();
110+
const frames = lib.createFrames<State>({
111+
initialState: {
112+
test: false,
113+
},
114+
});
115+
116+
const expressHandler = frames(async (ctx) => {
117+
expect(ctx.state).toEqual({ test: false });
118+
119+
return {
120+
image: 'http://test.png',
121+
state: ctx.state satisfies State,
122+
};
123+
});
124+
125+
app.use("/", expressHandler);
126+
127+
await request(app)
128+
.get("/")
129+
.expect("Content-Type", "text/html")
130+
.expect(200);
131+
});
104132
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Handler } from 'express';
2+
import { createFrames, types } from '.';
3+
4+
const framesWithoutState = createFrames();
5+
framesWithoutState(async ctx => {
6+
ctx.initialState satisfies types.JsonValue | undefined;
7+
ctx.state satisfies types.JsonValue | undefined;
8+
9+
return {
10+
image: 'http://test.png'
11+
};
12+
}) satisfies Handler;
13+
14+
const framesWithInferredState = createFrames({
15+
initialState: {
16+
test: true
17+
}
18+
});
19+
framesWithInferredState(async ctx => {
20+
ctx.initialState satisfies { test: boolean; };
21+
ctx.state satisfies {
22+
test: boolean;
23+
};
24+
25+
return {
26+
image: 'http://test.png'
27+
};
28+
}) satisfies Handler;
29+
30+
const framesWithExplicitState = createFrames<{
31+
test: boolean;
32+
}>({});
33+
framesWithExplicitState(async ctx => {
34+
ctx.state satisfies {
35+
test: boolean;
36+
};
37+
ctx.initialState satisfies {
38+
test: boolean;
39+
};
40+
ctx satisfies {
41+
message?: any;
42+
pressedButton?: any;
43+
request: Request;
44+
}
45+
46+
return {
47+
image: 'http://test.png'
48+
};
49+
}) satisfies Handler;

packages/frames.js/src/hono/index.test.tsx

+31
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,35 @@ describe("hono adapter", () => {
2828
expect(response.status).toBe(200);
2929
expect(response.headers.get("content-type")).toBe("text/html");
3030
});
31+
32+
it('works properly with state', async () => {
33+
type State = {
34+
test: boolean;
35+
};
36+
const frames = lib.createFrames<State>({
37+
initialState: {
38+
test: false,
39+
},
40+
});
41+
42+
const handler = frames(async (ctx) => {
43+
expect(ctx.state).toEqual({ test: false });
44+
45+
return {
46+
image: 'http://test.png',
47+
state: ctx.state satisfies State,
48+
};
49+
});
50+
51+
const app = new Hono();
52+
53+
app.on(["GET", "POST"], "/", handler);
54+
55+
const request = new Request("http://localhost:3000");
56+
57+
const response = await app.request(request);
58+
59+
expect(response.status).toBe(200);
60+
expect(response.headers.get("content-type")).toBe("text/html");
61+
});
3162
});

0 commit comments

Comments
 (0)