Skip to content

Commit 2290278

Browse files
committed
feat: application level errors support
1 parent 7b9caab commit 2290278

File tree

7 files changed

+172
-13
lines changed

7 files changed

+172
-13
lines changed

.changeset/modern-dolphins-float.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"frames.js": patch
3+
"docs": patch
4+
---
5+
6+
feat: application level errors support

docs/pages/reference/core/error.mdx

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# error
2+
3+
`error()` can be used to return an error response with a message to be presented to the user.
4+
5+
## Parameters
6+
7+
### `message`
8+
9+
Type: `string`
10+
11+
The message to be presented to the user.
12+
13+
### `status`
14+
15+
Type: `number`
16+
17+
Default: `400`
18+
19+
The status code of the response, must be a `4XX` status code.
20+
21+
## Usage
22+
23+
```tsx
24+
/* eslint-disable react/jsx-key */
25+
import { Button, createFrames, error } from "frames.js/core";
26+
27+
const frames = createFrames();
28+
const handleRequest = frames(async (ctx) => {
29+
if (ctx.message?.input) {
30+
// ...
31+
32+
// Input is invalid
33+
return error("Invalid input"); // Returns a response with status 400 and message "Invalid input"
34+
}
35+
36+
return {
37+
buttons: [<Button action="post">Click me</Button>],
38+
};
39+
});
40+
```

docs/vocs.config.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ const sidebar = [
135135
text: "redirect",
136136
link: "/reference/core/redirect",
137137
},
138+
{
139+
text: "error",
140+
link: "/reference/core/error",
141+
},
138142
],
139143
},
140144
{
@@ -294,6 +298,7 @@ const sidebar = [
294298
},
295299
];
296300

301+
// eslint-disable-next-line import/no-default-export -- default export is required
297302
export default defineConfig({
298303
ogImageUrl: "https://framesjs.org/og.png",
299304
title: "frames.js",

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

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { FrameMessageError } from "./errors";
2+
3+
/**
4+
* Throws an error for presentation to the user.
5+
* @param message - The error message (max 90 characters).
6+
* @param status - The 4XX HTTP status code to return (default: 400)
7+
*/
8+
export function error(message: string, status = 400): never {
9+
throw new FrameMessageError(message, status);
10+
}

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

+17
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,20 @@ export class InvalidFrameActionPayloadError extends Error {
99
super("Invalid frame action payload");
1010
}
1111
}
12+
13+
export class FrameMessageError extends Error {
14+
status: number;
15+
16+
/**
17+
*
18+
* @param message - Message to show the user (up to 90 characters)
19+
* @param status - 4XX status code
20+
*/
21+
constructor(message: string, status: number) {
22+
if (message.length > 90) throw new Error("Message too long");
23+
if (status < 400 || status >= 500) throw new Error("Invalid status code");
24+
25+
super(message);
26+
this.status = status;
27+
}
28+
}

packages/frames.js/src/middleware/renderResponse.test.tsx

+82-13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import * as vercelOg from "@vercel/og";
55
import { FRAMES_META_TAGS_HEADER } from "../core";
66
import { Button } from "../core/components";
7+
import { error } from "../core/error";
78
import { redirect } from "../core/redirect";
89
import type { FramesContext } from "../core/types";
910
import { renderResponse } from "./renderResponse";
@@ -31,8 +32,12 @@ jest.mock("@vercel/og", () => {
3132
});
3233

3334
describe("renderResponse middleware", () => {
34-
const arrayBufferMock: jest.Mock = (vercelOg as unknown as { arrayBufferMock: jest.Mock }).arrayBufferMock;
35-
const constructorMock: jest.Mock = (vercelOg as unknown as { constructorMock: jest.Mock }).constructorMock;
35+
const arrayBufferMock: jest.Mock = (
36+
vercelOg as unknown as { arrayBufferMock: jest.Mock }
37+
).arrayBufferMock;
38+
const constructorMock: jest.Mock = (
39+
vercelOg as unknown as { constructorMock: jest.Mock }
40+
).constructorMock;
3641
const render = renderResponse();
3742
const context: FramesContext<undefined> = {
3843
basePath: "/",
@@ -208,17 +213,79 @@ describe("renderResponse middleware", () => {
208213
);
209214
});
210215

216+
it("returns application error if error function is called", async () => {
217+
const result = await render(context, async () => {
218+
error("Custom error message");
219+
});
220+
221+
expect(result).toBeInstanceOf(Response);
222+
expect((result as Response).status).toBe(400);
223+
await expect((result as Response).json()).resolves.toEqual({
224+
message: "Custom error message",
225+
});
226+
});
227+
228+
it("returns application error if error function is called with custom status code", async () => {
229+
const result = await render(context, async () => {
230+
error("Custom error message", 401);
231+
});
232+
233+
expect(result).toBeInstanceOf(Response);
234+
expect((result as Response).status).toBe(401);
235+
await expect((result as Response).json()).resolves.toEqual({
236+
message: "Custom error message",
237+
});
238+
});
239+
240+
it("does not allow application errors with status codes other than 4XX", async () => {
241+
const result1 = await render(context, async () => {
242+
error("Custom error message", 200);
243+
});
244+
245+
expect(result1).toBeInstanceOf(Response);
246+
expect((result1 as Response).headers.get("Content-Type")).toBe(
247+
"text/plain"
248+
);
249+
expect((result1 as Response).status).toBe(500);
250+
await expect((result1 as Response).text()).resolves.toBe(
251+
"Internal Server Error"
252+
);
253+
254+
const result2 = await render(context, async () => {
255+
error("Custom error message", 500);
256+
});
257+
258+
expect(result2).toBeInstanceOf(Response);
259+
expect((result2 as Response).headers.get("Content-Type")).toBe(
260+
"text/plain"
261+
);
262+
expect((result2 as Response).status).toBe(500);
263+
await expect((result2 as Response).text()).resolves.toBe(
264+
"Internal Server Error"
265+
);
266+
});
267+
211268
it("returns 500 if invalid number of buttons is provided", async () => {
212269
// @ts-expect-error -- we are providing more than 4 buttons
213270
const result = await render(context, async () => {
214271
return {
215272
image: <div>My image</div>,
216273
buttons: [
217-
<Button action="post" key="1">Click me 1</Button>,
218-
<Button action="post" key="2">Click me 2</Button>,
219-
<Button action="post" key="3">Click me 3</Button>,
220-
<Button action="post" key="4">Click me 4</Button>,
221-
<Button action="post" key="5">Click me 5</Button>,
274+
<Button action="post" key="1">
275+
Click me 1
276+
</Button>,
277+
<Button action="post" key="2">
278+
Click me 2
279+
</Button>,
280+
<Button action="post" key="3">
281+
Click me 3
282+
</Button>,
283+
<Button action="post" key="4">
284+
Click me 4
285+
</Button>,
286+
<Button action="post" key="5">
287+
Click me 5
288+
</Button>,
222289
],
223290
};
224291
});
@@ -237,7 +304,9 @@ describe("renderResponse middleware", () => {
237304
image: <div>My image</div>,
238305
buttons: [
239306
// @ts-expect-error -- props are not matching the expected type
240-
<Button action="invalid" key="1">Click me 1</Button>,
307+
<Button action="invalid" key="1">
308+
Click me 1
309+
</Button>,
241310
],
242311
};
243312
});
@@ -336,7 +405,7 @@ describe("renderResponse middleware", () => {
336405
};
337406
});
338407

339-
const json = await (result as Response).json() as Record<string, string>;
408+
const json = (await (result as Response).json()) as Record<string, string>;
340409

341410
expect(json["fc:frame:button:1"]).toBe("Tx button");
342411
expect(json["fc:frame:button:1:action"]).toBe("tx");
@@ -368,7 +437,7 @@ describe("renderResponse middleware", () => {
368437

369438
expect(console.warn).toHaveBeenCalledTimes(1);
370439

371-
const json = await (result as Response).json() as Record<string, string>;
440+
const json = (await (result as Response).json()) as Record<string, string>;
372441

373442
expect(json.state).toBeUndefined();
374443
});
@@ -390,7 +459,7 @@ describe("renderResponse middleware", () => {
390459

391460
expect(console.warn).not.toHaveBeenCalled();
392461

393-
const json = await (result as Response).json() as Record<string, string>;
462+
const json = (await (result as Response).json()) as Record<string, string>;
394463

395464
expect(console.warn).not.toHaveBeenCalled();
396465
expect(json["fc:frame:state"]).toEqual(JSON.stringify({ test: true }));
@@ -463,7 +532,7 @@ describe("renderResponse middleware", () => {
463532

464533
expect(result).toBeInstanceOf(Response);
465534
expect((result as Response).status).toBe(200);
466-
const json = await (result as Response).json() as Record<string, string>;
535+
const json = (await (result as Response).json()) as Record<string, string>;
467536
expect(json["fc:frame:button:1:target"]).toBe(expectedUrl.toString());
468537
});
469538

@@ -496,7 +565,7 @@ describe("renderResponse middleware", () => {
496565

497566
expect(result).toBeInstanceOf(Response);
498567
expect((result as Response).status).toBe(200);
499-
const json = await (result as Response).json() as Record<string, string>;
568+
const json = (await (result as Response).json()) as Record<string, string>;
500569
expect(json).toMatchObject({
501570
"fc:frame:button:1": "Click me 1",
502571
"fc:frame:button:1:target": expect.any(String) as string,

packages/frames.js/src/middleware/renderResponse.ts

+12
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isFrameRedirect,
1616
} from "../core/utils";
1717
import { FRAMES_META_TAGS_HEADER } from "../core/constants";
18+
import { FrameMessageError } from "../core/errors";
1819

1920
class InvalidButtonShapeError extends Error {}
2021

@@ -39,6 +40,17 @@ export function renderResponse(): FramesMiddleware<any, Record<string, any>> {
3940
try {
4041
result = await next(context);
4142
} catch (e) {
43+
if (e instanceof FrameMessageError) {
44+
return Response.json(
45+
{
46+
message: e.message,
47+
},
48+
{
49+
status: e.status,
50+
}
51+
);
52+
}
53+
4254
// eslint-disable-next-line no-console -- provide feedback to the user
4355
console.error(e);
4456

0 commit comments

Comments
 (0)