Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.

Commit

Permalink
feat(server): add next.js experimental_caller()-adapter (trpc#5589)
Browse files Browse the repository at this point in the history
  • Loading branch information
KATT authored Mar 27, 2024
1 parent d818691 commit f1170b7
Show file tree
Hide file tree
Showing 52 changed files with 885 additions and 194 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const config = {
'unstable-*',
'script',
'URL',
'next-app-dir',
],
},
],
Expand Down
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@
"tailwindCSS.experimental.configFile": "./www/tailwind.config.ts",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"explorer.excludeGitIgnore": true
"explorer.excludeGitIgnore": true,
"typescript.preferences.autoImportFileExcludePatterns": [
"packages/server/src/unstable-core-do-not-import.ts"
]
}
3 changes: 2 additions & 1 deletion examples/.experimental/next-app-dir/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
"@types/node": "^20.10.0",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"next": "^14.0.1",
"next": "^14.1.4",
"next-auth": "^4.22.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.3",
"react-hot-toast": "^2.4.1",
"superjson": "^1.12.4",
"trpc-api": "link:./src/trpc",
"typescript": "^5.4.0",
Expand Down
2 changes: 2 additions & 0 deletions examples/.experimental/next-app-dir/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from 'next/link';
import { Toaster } from 'react-hot-toast';

export const metadata = {
title: 'Create Next App',
Expand Down Expand Up @@ -46,6 +47,7 @@ export default function RootLayout({
{children}
</div>
</main>
<Toaster />
</body>
</html>
);
Expand Down
14 changes: 14 additions & 0 deletions examples/.experimental/next-app-dir/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ export default async function Index() {
padding: 0,
}}
>
<li>
<Link
href="/posts"
style={{
color: 'hsla(210, 16%, 80%, 1)',
}}
>
Server Adapter{' '}
<em>
(only using <code>@trpc/server</code> &amp; no other{' '}
<code>@trpc/*</code>-packages)
</em>
</Link>
</li>
<li>
<Link
href="/rsc"
Expand Down
60 changes: 60 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/AddPostForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';

import toast from 'react-hot-toast';
import { addPost } from './_data';
import { addPostSchema } from './_data.schema';
import { Form, useZodForm } from './_lib/Form';

export function AddPostForm() {
const form = useZodForm({
schema: addPostSchema,
defaultValues: {
title: 'hello world',
content: 'this is a test post',
},
});

return (
<Form
form={form}
handleSubmit={async (values) => {
// ^?
const promise = addPost(values);
return toast
.promise(promise, {
loading: 'Adding post...',
success: 'Post added!',
error: (error) => 'Failed to add post: ' + error.message,
})
.catch((error) => {
console.warn('Failed to add post', error);
});
}}
>
<div>
<input
type="text"
{...form.register('title')}
defaultValue={form.control._defaultValues.title}
/>
{form.formState.errors.title && (
<div>Invalid title: {form.formState.errors.title.message}</div>
)}
</div>

<div>
<input
type="text"
{...form.register('content')}
defaultValue={form.control._defaultValues.content}
/>
{form.formState.errors.content && (
<div>Invalid content: {form.formState.errors.content.message}</div>
)}
</div>
<button type="submit" disabled={form.formState.isSubmitting}>
Add post
</button>
</Form>
);
}
17 changes: 17 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { postById } from '../_data';

export default async function Page(props: {
params: {
id: string;
};
}) {
const post = await postById(props.params);

return (
<div>
<h1>{post.title}</h1>

<p>{post.content}</p>
</div>
);
}
11 changes: 11 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/_data.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod';

export const postSchema = z.object({
id: z.string(),
title: z.string().min(1),
content: z.string().min(1),
});

export type Post = z.infer<typeof postSchema>;

export const addPostSchema = postSchema.omit({ id: true });
53 changes: 53 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use server';

import { revalidatePath } from 'next/cache';
import { RedirectType } from 'next/navigation';
import { z } from 'zod';
import { addPostSchema, type Post } from './_data.schema';
import { db } from './_lib/db';
import { nextProc, notFound, redirect } from './_lib/trpc';

export const addPost = nextProc
.input(
addPostSchema.superRefine(async (it, ctx) => {
const posts = await db.listPosts();
console.log('posts in db', posts);
if (posts.some((post) => post.title === it.title)) {
ctx.addIssue({
code: 'custom',
message: 'Title already exists',
path: ['title'],
});
}
}),
)
.mutation(async (opts) => {
await new Promise((resolve) => setTimeout(resolve, 300));
const post: Post = {
...opts.input,
id: `${Math.random()}`,
};

await db.addPost(post);
revalidatePath('/');
redirect(`/posts/${post.id}`, RedirectType.push);
});

export const listPosts = nextProc.query(async () => {
return await db.listPosts();
});

export const postById = nextProc
.input(
z.object({
id: z.string(),
}),
)
.query(async (opts) => {
const post = await db.getPost(opts.input.id);
if (!post) {
console.warn(`Post with id ${opts.input.id} not found`);
notFound();
}
return post;
});
50 changes: 50 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/_lib/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import type { z } from 'zod';

/**
* Reusable hook for zod + react-hook-form
*/
export function useZodForm<TInput extends FieldValues>(
props: Omit<UseFormProps<TInput>, 'resolver'> & {
schema: z.ZodType<any, any, TInput>;
},
) {
const form = useForm<TInput>({
...props,
resolver: zodResolver(props.schema, undefined, {
rawValues: true,
}),
});

return form;
}

export const Form = <TInput extends FieldValues = never>(props: {
children: React.ReactNode;
form: UseFormReturn<TInput>;
handleSubmit: (values: NoInfer<TInput>) => Promise<unknown>;
}) => {
return (
<FormProvider {...props.form}>
<form
onSubmit={(event) => {
return props.form.handleSubmit(async (values) => {
try {
await props.handleSubmit(values);
} catch (error) {
console.error('Uncaught error in form', error);
toast.error('Failed to submit form');
}
})(event);
}}
>
{props.children}
</form>
</FormProvider>
);
};
39 changes: 39 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/_lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type Post } from '../_data.schema';

interface PostDb {
getPost: (id: string) => Promise<Post | undefined>;
addPost: (post: Post) => Promise<void>;
listPosts: () => Promise<Post[]>;
}
const mockDb = (): PostDb => {
console.log('⚙️ Using mock DB');
const posts: Post[] = [
{
id: '1',
title: 'Hello world',
content: 'This is a test post',
},
{
id: '2',
title: 'Second post',
content: 'This is another test post',
},
];
return {
getPost: async (id) => {
return posts.find((post) => post.id === id);
},
addPost: async (post) => {
posts.push(post);
},
listPosts: async () => {
return posts;
},
};
};
const pgDb = (): PostDb => {
console.log('🚀 Using PG store');

throw new Error('Not implemented');
};
export const db = process.env.POSTGRES_URL ? pgDb() : mockDb();
24 changes: 24 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/_lib/trpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { initTRPC } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';

const t = initTRPC.create();

export const nextProc = t.procedure
.use(async function artificialDelay(opts) {
if (t._config.isDev) {
// random number between 100 and 500ms
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
return opts.next();
})
.experimental_caller(
experimental_nextAppDirCaller({
normalizeFormData: true,
}),
);

export {
experimental_notFound as notFound,
experimental_redirect as redirect,
} from '@trpc/server/adapters/next-app-dir';
26 changes: 26 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { useEffect } from 'react';
import toast from 'react-hot-toast';

let toasted = false;
export default function PostLayout(props: { children: React.ReactNode }) {
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
const timeout = setTimeout(() => {
if (toasted) return;
toasted = true;
toast(
'Please note that we have an artificial delay on the server functions to simulate real-world conditions.',
{
icon: '🐌',
},
);
}, 1);
return () => {
clearTimeout(timeout);
};
}
}, []);
return props.children;
}
23 changes: 23 additions & 0 deletions examples/.experimental/next-app-dir/src/app/posts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { listPosts } from './_data';
import { AddPostForm } from './AddPostForm';

export default async function Home() {
const posts = await listPosts();

return (
<div>
<h1>All posts</h1>

<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>

<h2>Add post</h2>
<AddPostForm />
</div>
);
}
2 changes: 1 addition & 1 deletion examples/.experimental/next-formdata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@trpc/next": "npm:@trpc/next@next",
"@trpc/react-query": "npm:@trpc/react-query@next",
"@trpc/server": "npm:@trpc/server@next",
"next": "^14.0.1",
"next": "^14.1.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.3",
Expand Down
2 changes: 1 addition & 1 deletion examples/.test/diagnostics-big-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@trpc/next": "npm:@trpc/next@next",
"@trpc/react-query": "npm:@trpc/react-query@next",
"@trpc/server": "npm:@trpc/server@next",
"next": "^14.0.1",
"next": "^14.1.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.0.0"
Expand Down
Loading

0 comments on commit f1170b7

Please sign in to comment.