Skip to content

Commit

Permalink
feat: errorFormatter (#195)
Browse files Browse the repository at this point in the history
Co-authored-by: Mark R. Florkowski <[email protected]>
  • Loading branch information
juliusmarminge and markflorkowski authored Jul 7, 2023
1 parent f3640fb commit a6c969e
Show file tree
Hide file tree
Showing 21 changed files with 474 additions and 114 deletions.
7 changes: 7 additions & 0 deletions .changeset/ten-plums-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@uploadthing/react": minor
"@uploadthing/shared": minor
"uploadthing": minor
---

feat: improve errors and add `errorFormatter` option on the backend
59 changes: 59 additions & 0 deletions docs/src/pages/errors.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Error Handling

## Error Formatting

You can customize the server-side behavior in your API handler's options by
using an error formatter.

By default, only the error message is returned to the client, to avoid leaking
any sensitive information. You can customize this behavior by specifying the
`errorFormatter` option when you initialize your file route helper. An error
formatter runs on the server and takes the original `UploadThingError`, and
returns a JSON-serializable object. The error also includes a `cause` property
which contains more information about the nature of the error and what caused
the error to throw in the first place.

For example, if you're using Zod as an input parser, you can return information
of what fields failed validation by checking if the cause is a `ZodError`. Zod
provides a `flatten` method that returns a JSON-serializable object which we can
return to the client.

```ts filename="server/uploadthing.ts"
import * as z from "zod";

import { createUploadthing } from "uploadthing/next";
import type { FileRouter } from "uploadthing/next";

const f = createUploadthing({
errorFormatter: (err) => {
return {
message: err.message,
zodError: err.cause instanceof z.ZodError ? err.cause.flatten() : null,
};
},
});

export const uploadRouter = {
withInput: f.input(z.object({ foo: z.string() })),
// ...
} satisfies FileRouter;
```

## Catching errors on the client

You can catch errors on the client by using the `onUploadFailed` property on the
premade components, or the `useUploadthing` hook. You can access the JSON object
that you returned from your error formatter on the `data` property:

```tsx
<UploadButton
endpoint="withInput"
input={{ foo: userInput }}
onUploadError={(error) => {
console.log("Error: ", error);
const fieldErrors = error.data?.zodError?.fieldErrors;
// ^? typeToFlattenedError
setError(fieldErrors.foo[0] ?? "");
}}
/>
```
13 changes: 10 additions & 3 deletions examples/appdir/src/app/sink/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UploadButton, UploadDropzone } from "~/utils/uploadthing";

export default function Home() {
const [userInput, setUserInput] = useState("");
const [error, setError] = useState("");

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-16 p-24">
Expand All @@ -17,6 +18,7 @@ export default function Home() {
value={userInput}
onChange={(e) => setUserInput(e.currentTarget.value)}
/>
{error && <div className="text-red-500">{error}</div>}
</div>
<div className="flex flex-col items-center justify-center gap-4">
<div className="flex gap-4">
Expand All @@ -27,8 +29,13 @@ export default function Home() {
console.log("Files: ", res);
alert("Upload Completed");
}}
onUploadError={(error: Error) => {
alert(`ERROR! ${error.message}`);
onUploadError={(error) => {
console.log("Error: ", error);
if (error.data?.zodError?.fieldErrors.foo) {
setError(error.data?.zodError?.fieldErrors.foo[0]);
} else {
setError("");
}
}}
/>

Expand All @@ -38,7 +45,7 @@ export default function Home() {
console.log("Files: ", res);
alert("Upload Completed");
}}
onUploadError={(error: Error) => {
onUploadError={(error) => {
alert(`ERROR! ${error.message}`);
}}
/>
Expand Down
9 changes: 8 additions & 1 deletion examples/appdir/src/server/uploadthing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import * as z from "zod";
import { createUploadthing } from "uploadthing/next";
import type { FileRouter } from "uploadthing/next";

const f = createUploadthing();
const f = createUploadthing({
errorFormatter: (err) => {
return {
message: err.message,
zodError: err.cause instanceof z.ZodError ? err.cause.flatten() : null,
};
},
});

export const uploadRouter = {
videoAndImage: f({
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"build": "turbo run build",
"build:pkgs": "turbo run build --filter \"./packages/*\"",
"clean": "turbo run clean && git clean -xdf node_modules",
"dev": "turbo run dev",
"dev:pkgs": "turbo run dev --filter \"./packages/*\"",
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { useCallback, useRef, useState } from "react";
import type { FileWithPath } from "react-dropzone";
import { useDropzone } from "react-dropzone";

import type { ExpandedRouteConfig } from "@uploadthing/shared";
import type {
ExpandedRouteConfig,
UploadThingError,
} from "@uploadthing/shared";
import type { UploadFileType } from "uploadthing/client";
import {
classNames,
Expand All @@ -13,6 +16,7 @@ import type {
ErrorMessage,
FileRouter,
inferEndpointInput,
inferErrorShape,
} from "uploadthing/server";

import { INTERNAL_uploadthingHookGen } from "./useUploadThing";
Expand Down Expand Up @@ -75,7 +79,7 @@ export type UploadthingComponentProps<TRouter extends FileRouter> = {
onClientUploadComplete?: (
res?: Awaited<ReturnType<UploadFileType<TRouter>>>,
) => void;
onUploadError?: (error: Error) => void;
onUploadError?: (error: UploadThingError<inferErrorShape<TRouter>>) => void;
} & (undefined extends inferEndpointInput<TRouter[TEndpoint]>
? {}
: {
Expand Down
29 changes: 20 additions & 9 deletions packages/react/src/useUploadThing.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { useRef, useState } from "react";

import type { ExpandedRouteConfig } from "@uploadthing/shared";
import { UploadThingError } from "@uploadthing/shared";
import { DANGEROUS__uploadFiles } from "uploadthing/client";
import type { FileRouter, inferEndpointInput } from "uploadthing/server";
import type {
FileRouter,
inferEndpointInput,
inferErrorShape,
} from "uploadthing/server";

import { useEvent } from "./utils/useEvent";
import useFetch from "./utils/useFetch";
Expand All @@ -17,18 +22,23 @@ const useEndpointMetadata = (endpoint: string) => {
return data?.find((x) => x.slug === endpoint);
};

export type UseUploadthingProps = {
export type UseUploadthingProps<TRouter extends FileRouter> = {
onClientUploadComplete?: (
res?: Awaited<ReturnType<typeof DANGEROUS__uploadFiles>>,
) => void;
onUploadProgress?: (p: number) => void;
onUploadError?: (e: Error) => void;
onUploadError?: (e: UploadThingError<inferErrorShape<TRouter>>) => void;
};

const fatalClientError = new UploadThingError({
code: "INTERNAL_CLIENT_ERROR",
message: "Something went wrong. Please report this to UploadThing.",
});

export const INTERNAL_uploadthingHookGen = <TRouter extends FileRouter>() => {
const useUploadThing = <TEndpoint extends keyof TRouter>(
endpoint: TEndpoint,
opts?: UseUploadthingProps,
opts?: UseUploadthingProps<TRouter>,
) => {
const [isUploading, setUploading] = useState(false);
const uploadProgress = useRef(0);
Expand Down Expand Up @@ -64,17 +74,18 @@ export const INTERNAL_uploadthingHookGen = <TRouter extends FileRouter>() => {
}
},
});
setUploading(false);
fileProgress.current = new Map();
uploadProgress.current = 0;

opts?.onClientUploadComplete?.(res);
return res;
} catch (e) {
const error = e instanceof UploadThingError ? e : fatalClientError;
opts?.onUploadError?.(
error as UploadThingError<inferErrorShape<TRouter>>,
);
} finally {
setUploading(false);
fileProgress.current = new Map();
uploadProgress.current = 0;
opts?.onUploadError?.(e as Error);
return;
}
});

Expand Down
96 changes: 96 additions & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Json } from "./types";

const ERROR_CODES = {
BAD_REQUEST: 400,
NOT_FOUND: 404,

INTERNAL_SERVER_ERROR: 500,
INTERNAL_CLIENT_ERROR: 500,
URL_GENERATION_FAILED: 500,
UPLOAD_FAILED: 500,
MISSING_ENV: 500,
FILE_LIMIT_EXCEEDED: 500,
} as const;

type ErrorCode = keyof typeof ERROR_CODES;

function messageFromUnknown(cause: unknown, fallback?: string) {
if (typeof cause === "string") {
return cause;
}
if (cause instanceof Error) {
return cause.message;
}
if (
cause &&
typeof cause === "object" &&
"message" in cause &&
typeof cause.message === "string"
) {
return cause.message;
}
return fallback ?? "An unknown error occurred";
}

export class UploadThingError<
TShape extends Json = { message: string },
> extends Error {
public readonly cause?: Error;
public readonly code: ErrorCode;
public readonly data?: TShape;

constructor(opts: {
code: keyof typeof ERROR_CODES;
message?: string;
cause?: unknown;
data?: TShape;
}) {
const message = opts.message ?? messageFromUnknown(opts.cause, opts.code);

super(message);
this.code = opts.code;
this.data = opts.data;

if (opts.cause instanceof Error) {
this.cause = opts.cause;
} else if (opts.cause instanceof Response) {
this.cause = new Error(
`Response ${opts.cause.status} ${opts.cause.statusText}`,
);
} else if (typeof opts.cause === "string") {
this.cause = new Error(opts.cause);
} else {
this.cause = undefined;
}
}

public static async fromResponse(response: Response) {
const json = (await response.json()) as Json;
const message =
json &&
typeof json === "object" &&
"message" in json &&
typeof json.message === "string"
? json.message
: undefined;
return new UploadThingError({
message,
code: getErrorTypeFromStatusCode(response.status),
cause: response,
data: json,
});
}
}

export function getStatusCodeFromError(error: UploadThingError<any>) {
return ERROR_CODES[error.code] ?? 500;
}

function getErrorTypeFromStatusCode(statusCode: number): ErrorCode {
for (const [code, status] of Object.entries(ERROR_CODES)) {
if (status === statusCode) {
return code as ErrorCode;
}
}
return "INTERNAL_SERVER_ERROR";
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./types";
export * from "./utils";
export * from "./file-types";
export * from "./error";
5 changes: 5 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type { MimeType } from "@uploadthing/mime-types/db";

import type { AllowedFileType } from "./file-types";

export type JsonValue = string | number | boolean | null | undefined;
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue | JsonObject | JsonArray };
export type Json = JsonValue | JsonObject | JsonArray;

/** This matches the return type from the infra */
export interface FileData {
id: string;
Expand Down
Loading

1 comment on commit a6c969e

@vercel
Copy link

@vercel vercel bot commented on a6c969e Jul 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.