Skip to content

Commit 65dc3f9

Browse files
KATTNick-LucasSheraff
authored
chore(server): rewrite resolveHTTPResponse with Fetch (#5684)
--------- Co-authored-by: Nick Lucas <[email protected]> Co-authored-by: Sheraff <[email protected]>
0 parents  commit 65dc3f9

File tree

17 files changed

+541
-0
lines changed

17 files changed

+541
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
public/uploads

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Next.js + tRPC + `FormData`
2+
3+
This example showcases how to use tRPC with `FormData`.
4+
5+
## Setup
6+
7+
```bash
8+
npx create-next-app --example https://github.com/trpc/trpc --example-path examples/next-formdata trpc-formdata
9+
cd trpc-formdata
10+
npm i
11+
npm run dev
12+
```

next-env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/basic-features/typescript for more information.

next.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** @type {import("next").NextConfig} */
2+
module.exports = {
3+
/** We run eslint as a separate task in CI */
4+
eslint: { ignoreDuringBuilds: !!process.env.CI },
5+
};

package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "examples-next-formdata",
3+
"private": true,
4+
"scripts": {
5+
"dev": "next dev",
6+
"build": "next build",
7+
"lint": "eslint --ext \".js,.ts,.tsx\" src",
8+
"start": "next start"
9+
},
10+
"dependencies": {
11+
"@hookform/error-message": "^2.0.1",
12+
"@hookform/resolvers": "^2.9.11",
13+
"@tanstack/react-query": "^5.25.0",
14+
"@trpc/client": "npm:@trpc/client@next",
15+
"@trpc/next": "npm:@trpc/next@next",
16+
"@trpc/react-query": "npm:@trpc/react-query@next",
17+
"@trpc/server": "npm:@trpc/server@next",
18+
"next": "^14.1.4",
19+
"react": "^18.3.0",
20+
"react-dom": "^18.3.0",
21+
"react-hook-form": "^7.43.3",
22+
"zod": "^3.0.0",
23+
"zod-form-data": "^2.0.1"
24+
},
25+
"devDependencies": {
26+
"@types/node": "^20.10.0",
27+
"@types/react": "^18.3.0",
28+
"@types/react-dom": "^18.3.0",
29+
"eslint": "^8.56.0",
30+
"typescript": "^5.4.0"
31+
}
32+
}

src/pages/_app.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { AppType } from 'next/app';
2+
import { trpc } from '../utils/trpc';
3+
4+
const MyApp: AppType = ({ Component, pageProps }) => {
5+
return <Component {...pageProps} />;
6+
};
7+
8+
export default trpc.withTRPC(MyApp);

src/pages/api/trpc/[trpc].ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* This is the API-handler of your app that contains all your API routes.
3+
* On a bigger app, you will probably want to split this file up into multiple files.
4+
*/
5+
import * as trpcNext from '@trpc/server/adapters/next';
6+
import { roomRouter } from '~/server/routers/room';
7+
import { createContext, router } from '~/server/trpc';
8+
import type { NextApiRequest, NextApiResponse } from 'next';
9+
10+
const appRouter = router({
11+
room: roomRouter,
12+
});
13+
14+
// export only the type definition of the API
15+
// None of the actual implementation is exposed to the client
16+
export type AppRouter = typeof appRouter;
17+
18+
const handler = trpcNext.createNextApiHandler({
19+
router: appRouter,
20+
createContext,
21+
});
22+
23+
// export API handler
24+
export default async (req: NextApiRequest, res: NextApiResponse) => {
25+
await handler(req, res);
26+
};
27+
28+
export const config = {
29+
api: {
30+
bodyParser: false,
31+
responseLimit: '100mb',
32+
},
33+
};

src/pages/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Link from 'next/link';
2+
3+
export default function IndexPage() {
4+
return (
5+
<ul>
6+
<li>
7+
<Link href="/vanilla">/vanilla</Link>
8+
</li>
9+
<li>
10+
<Link href="/react-hook-form">/react-hook-form</Link>
11+
</li>
12+
</ul>
13+
);
14+
}

src/pages/react-hook-form.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { uploadFileSchema } from '~/utils/schemas';
3+
import { trpc } from '~/utils/trpc';
4+
import { useRef, useState } from 'react';
5+
import type { UseFormProps } from 'react-hook-form';
6+
import { FormProvider, useForm } from 'react-hook-form';
7+
import type { z } from 'zod';
8+
9+
/**
10+
* zod-form-data wraps zod in an effect where the original type is a `FormData`
11+
*/
12+
type UnwrapZodEffect<TType extends z.ZodType> = TType extends z.ZodEffects<
13+
infer U,
14+
any,
15+
any
16+
>
17+
? U
18+
: TType;
19+
20+
type GetInput<TType extends z.ZodType> = UnwrapZodEffect<TType>['_input'];
21+
22+
function useZodFormData<TSchema extends z.ZodType>(
23+
props: Omit<UseFormProps<GetInput<TSchema>>, 'resolver'> & {
24+
schema: TSchema;
25+
},
26+
) {
27+
const formRef = useRef<HTMLFormElement>(null);
28+
const _resolver = zodResolver(props.schema, undefined, {
29+
rawValues: true,
30+
});
31+
32+
const form = useForm<GetInput<TSchema>>({
33+
...props,
34+
resolver: (_, ctx, opts) => {
35+
if (!formRef.current) {
36+
return {
37+
values: {},
38+
errors: {
39+
root: {
40+
message: 'Form not mounted',
41+
},
42+
},
43+
};
44+
}
45+
const values = new FormData(formRef.current);
46+
return _resolver(values, ctx, opts);
47+
},
48+
});
49+
50+
return { ...form, formRef };
51+
}
52+
53+
export default function Page() {
54+
const mutation = trpc.room.sendMessage.useMutation({
55+
onError(err) {
56+
alert('Error from server: ' + err.message);
57+
},
58+
});
59+
60+
const form = useZodFormData({
61+
schema: uploadFileSchema,
62+
defaultValues: {
63+
name: 'whadaaaap',
64+
},
65+
});
66+
67+
const [noJs, setNoJs] = useState(false);
68+
69+
return (
70+
<>
71+
<h2 className="text-3xl font-bold">Posts</h2>
72+
73+
<FormProvider {...form}>
74+
<form
75+
method="post"
76+
action={`/api/trpc/${mutation.trpc.path}`}
77+
encType="multipart/form-data"
78+
onSubmit={(_event) => {
79+
if (noJs) {
80+
return;
81+
}
82+
void form.handleSubmit(async (values, event) => {
83+
await mutation.mutateAsync(new FormData(event?.target));
84+
})(_event);
85+
}}
86+
style={{ display: 'flex', flexDirection: 'column', gap: 10 }}
87+
ref={form.formRef}
88+
>
89+
<fieldset>
90+
<legend>Form with file upload</legend>
91+
<div style={{}}>
92+
<label htmlFor="name">Enter your name</label>
93+
<input {...form.register('name')} />
94+
{form.formState.errors.name && (
95+
<div>{form.formState.errors.name.message}</div>
96+
)}
97+
</div>
98+
99+
<div>
100+
<label>Required file, only images</label>
101+
<input type="file" {...form.register('image')} />
102+
{form.formState.errors.image && (
103+
<div>{form.formState.errors.image.message}</div>
104+
)}
105+
</div>
106+
107+
<div>
108+
<label>Post without JS</label>
109+
<input
110+
type="checkbox"
111+
checked={noJs}
112+
onChange={(e) => {
113+
setNoJs(e.target.checked);
114+
}}
115+
/>
116+
</div>
117+
<div>
118+
<button type="submit" disabled={mutation.status === 'pending'}>
119+
submit
120+
</button>
121+
</div>
122+
</fieldset>
123+
</form>
124+
125+
{mutation.data && (
126+
<fieldset>
127+
<legend>Upload result</legend>
128+
<ul>
129+
<li>
130+
Image: <br />
131+
<img
132+
src={mutation.data.image.url}
133+
alt={mutation.data.image.url}
134+
/>
135+
</li>
136+
</ul>
137+
</fieldset>
138+
)}
139+
</FormProvider>
140+
</>
141+
);
142+
}

src/pages/vanilla.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* This is a Next.js page.
3+
*/
4+
import { trpc } from '../utils/trpc';
5+
6+
export default function IndexPage() {
7+
const mutation = trpc.room.sendMessage.useMutation({
8+
onSuccess() {
9+
alert('success!');
10+
},
11+
onError(err) {
12+
alert('Error: ' + err.message);
13+
},
14+
});
15+
16+
return (
17+
<>
18+
{/**
19+
* The type is defined and can be autocompleted
20+
* 💡 Tip: Hover over `data` to see the result type
21+
* 💡 Tip: CMD+Click (or CTRL+Click) on `text` to go to the server definition
22+
* 💡 Tip: Secondary click on `text` and "Rename Symbol" to rename it both on the client & server
23+
*/}
24+
<h1>Form!</h1>
25+
<fieldset>
26+
<legend>Form with file upload</legend>
27+
<form
28+
method="post"
29+
action={`/api/trpc/${mutation.trpc.path}`}
30+
encType="multipart/form-data"
31+
onSubmit={(e) => {
32+
const formData = new FormData(e.currentTarget);
33+
if (formData.get('nojs')) {
34+
// Submit the form the oldschool way
35+
return;
36+
}
37+
38+
mutation.mutate(formData);
39+
e.preventDefault();
40+
}}
41+
>
42+
<p>
43+
<input name="name" defaultValue="haz upload" />
44+
</p>
45+
<p>
46+
<input type="file" name="image" />
47+
</p>
48+
49+
<p>
50+
<input type="checkbox" id="nojs" name="nojs" value="1" />{' '}
51+
<label htmlFor="nojs">Do oldschool POST w/o JS</label>
52+
</p>
53+
<p>
54+
<button type="submit">submit</button>
55+
</p>
56+
</form>
57+
</fieldset>
58+
59+
{mutation.data && (
60+
<fieldset>
61+
<legend>Upload result</legend>
62+
<ul>
63+
<li>
64+
Image: <br />
65+
<img
66+
src={mutation.data.image.url}
67+
alt={mutation.data.image.url}
68+
/>
69+
</li>
70+
</ul>
71+
</fieldset>
72+
)}
73+
</>
74+
);
75+
}

0 commit comments

Comments
 (0)