在Astro及其(反应)岛中使用TRPC
#javascript #网络开发人员 #react #astro

我开始在某些工作中使用TRPC。 因此,我决定还在使用Astro的网站上实现TRPC。
在Astro中开始使用TRPC,需要采取一些步骤。

安装所需软件包

npm install @tanstack/react-query @trpc/client @trpc/server @trpc/react-query

设置TRPC上下文

// /src/server/context.ts

import { getUser } from '@astro-auth/core';
import type { inferAsyncReturnType } from '@trpc/server';
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';

export function createContext({
  req,
  resHeaders,
}: FetchCreateContextFnOptions) {
  const user = getUser({ server: req });
  return { req, resHeaders, user };
}

export type Context = inferAsyncReturnType<typeof createContext>;

因为我想在调用TRPC路由时检查当前登录的用户,所以我添加了@astro-authgetUser()调用。通过将其添加到上下文中,我可以在以后的中间件中使用用户(请参见下文)。

设置TRPC服务器

// src/server/router.ts

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { prisma } from '../lib/prisma';
import type { Comment } from '@prisma/client';
import type { Context } from './context';

export const t = initTRPC.context<Context>().create();

export const middleware = t.middleware;
export const publicProcedure = t.procedure;

const isAdmin = middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});

export const adminProcedure = publicProcedure.use(isAdmin);

export const appRouter = t.router({
  getCommentsForBlog: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const blogUrl = input.replace('src/content', '').replace('.mdx', '');
      const commentsForBlogUrl = await prisma?.post.findFirst({
        where: { url: (blogUrl as string) ?? undefined },
        include: { Comment: { orderBy: { createdAt: 'desc' } } },
      });
      const allCommentsInDbForPost = commentsForBlogUrl?.Comment;
      return allCommentsInDbForPost ?? null;
    }),
  createCommentForBlog: publicProcedure
    .input(
      z.object({
        comment: z.string(),
        author: z.string(),
        blogUrl: z.string(),
      })
    )
    .mutation(async ({ input }) => {
      const { comment, blogUrl, author } = input;
      let commentInDb: Comment | undefined;
      const blog = await prisma?.post.findFirst({
        where: { url: blogUrl },
      });
      try {
        commentInDb = await prisma?.comment.create({
          data: {
            author: author ?? '',
            text: comment ?? '',
            post: {
              connectOrCreate: {
                create: {
                  url: blogUrl ?? '',
                },
                where: {
                  id: blog?.id ?? 0,
                },
              },
            },
          },
        });
      } catch (err) {
        console.error('Error saving comment', err);
        return { status: 'error', error: 'Error saving comment' };
      }
      if (!commentInDb) {
        return { status: 'error', error: 'Error saving comment' };
      }
      return { status: 'success' };
    }),
  deleteCommentForBlog: adminProcedure
    .input(z.object({ id: z.number() }))
    .mutation(async ({ input }) => {
      let deleteComment;
      try {
        deleteComment = await prisma?.comment.delete({
          where: {
            id: input.id,
          },
        });
      } catch (e) {
        return { status: 'error', error: 'Error deleting comment' };
      }
      if (!deleteComment) {
        return { status: 'error', error: 'Error deleting comment' };
      }

      return { status: 'success' };
    }),
  sendContactForm: publicProcedure
    .input(
      z.object({ email: z.string().nullable(), message: z.string().nullable() })
    )
    .mutation(async ({ input }) => {
      if (input.email && input.message) {
        await fetch(import.meta.env.FORMSPREE_URL!, {
          method: 'post',
          headers: {
            Accept: 'application/json',
          },
          body: JSON.stringify(input),
        }).catch(e => {
          console.error(e);
          return { status: 'error' };
        });
        return { status: 'success' };
      }
      return { status: 'missingdata' };
    }),
});

export type AppRouter = typeof appRouter;

我创建了一个单独的过程adminProcedure,我在该过程上应用了中间件。这将确保仅在登录用户登录时才能调用此过程的任何路由。
创建过程后,我声明了不同的路线。检查deleteCommentForBlog路线,这是adminProcedure后面的路线。

在Astro设置API路线

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

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import type { APIRoute } from 'astro';
import { createContext } from '../../../server/context';
import { appRouter } from '../../../server/router';

export const all: APIRoute = ({ request }) => {
  return fetchRequestHandler({
    endpoint: '/api/trpc',
    req: request,
    router: appRouter,
    createContext,
  });
};

我将使用fetch adapter处理从客户端到TRPC路由器的请求。
这是可能的,因为Astro使用内置的Web Platform Apis ResponseRequest
我们链接路由器和上下文,我们准备设置TRPC客户端。

设置TRPC客户端

因为我想在Astro页面以及islands上使用trpc。
我的岛屿是使用React创建的,我也会设置TRPC React客户端。

// /src/client/index.ts

import { createTRPCReact } from '@trpc/react-query';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';

const trpcReact = createTRPCReact<AppRouter>();

const trpcAstro = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});

export { trpcReact, trpcAstro };

在.ASTRO文件中使用TRPC客户端

我将使用Astro TRPC客户端从客户端上的<script>标签通信到TRPC路由。

// /src/pages/context/index.astro export const prerender = true; import Layout
from '../../layouts/Layout.astro';

<form id="contactForm">
  <label class="flex flex-col gap-2 mb-4" for="email">
    Your e-mail
    <input
      class="py-2 px-4 bg-white border-secondary border-2 rounded-lg"
      id="email"
      type="email"
      name="email"
      placeholder="info@example.com"
      required
    />
  </label>
  <label class="flex flex-col gap-2" for="message">
    Your message
    <textarea
      class="py-2 px-4 bg-white border-secondary border-2 rounded-lg"
      rows={3}
      id="message"
      name="message"
      placeholder="Hey, I would like to get in touch with you"
      required></textarea>
  </label>

  <button
    class="px-8 mt-4 py-4 bg-secondary text-white rounded-lg lg:hover:scale-[1.04] transition-transform disabled:opacity-50"
    type="submit"
    id="submitBtn"
  >
    Submit
  </button>
  <div id="missingData" class="text-red-500 font-bold hidden">
    Something went from while processing the contact form. Try again later.
  </div>
  <div id="error" class="text-red-500 font-bold hidden">
    Something went from while processing the contact form. Try again later.
  </div>
</form>
<script>
  import { trpcAstro } from '../../client';
  const form = document.getElementById('contactForm') as HTMLFormElement | null;
  form?.addEventListener('submit', async e => {
    e.preventDefault();
    const formData = new FormData(form);
    const result = await trpcAstro.sendContactForm.mutate({
      message: formData.get('message') as string | null,
      email: formData.get('email') as string | null,
    });
    if (result.status === 'success') {
      window.location.href = '/contact/thanks';
    }
  });
</script>

因为我正在使用TRPC客户端,所以我在代码上获得自动完成,并且我确切地知道该路线的输入和将返回的内容的预期!

在React Islands中使用TRPC客户端

我决定与@tanstack/react-query合作,以促进我的React代码中的更轻松的获取/突变。
因此,我需要实例化trpc客户端 QueryClient for react-query
我在包装器组件中进行了此操作,该组件包装了将打电话给TRPC路由的实际组件。

// /src/components/CommentOverviewWrapper.tsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CommentOverview } from './CommentOverview';
import { trpcReact } from '../client';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';

const CommentsOverviewWrapper = () => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpcReact.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  );
  return (
    <trpcReact.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <CommentOverview />
      </QueryClientProvider>
    </trpcReact.Provider>
  );
};
export default CommentsOverviewWrapper;

实际组件最终看起来像这样:

// /src/components/CommentOverview.tsx

import type { Comment } from '@prisma/client';
import { trpcReact } from '../client';

const CommentOverview = () => {
  const upToDateCommentsQuery = trpcReact.getAllComments.useQuery();
  const { mutate: deleteComment } = trpcReact.deleteCommentForBlog.useMutation({
    onError: () => {
      console.error('Error deleting comment');
    },
    onSuccess: res => {
      if (res.status === 'error') {
        console.log('Succesfully deleted comment');
      }
    },
    onSettled: () => {
      upToDateCommentsQuery.refetch();
    },
  });

  const commentsReduced = upToDateCommentsQuery?.data?.reduce<{
    [key: string]: typeof upToDateCommentsQuery.data;
  }>(
    (acc, cur) => ({
      ...acc,
      [cur.post.url]: [...(acc[cur.post.url] || []), cur],
    }),
    {}
  );
  return (
    <div className="grid lg:grid-cols-2 gap-6">
      {commentsReduced
        ? Object.entries(commentsReduced).map(([key, val]) => {
            return (
              <div key={key}>
                <h2 className="font-bold mb-4 text-xl">{key}</h2>
                <ul className="flex flex-col gap-y-2">
                  {val.map(comment => (
                    <div className="flex gap-x-2" key={comment.id}>
                      <button
                        type="button"
                        onClick={() => {
                          deleteComment({ id: comment.id });
                        }}
                      >
                        <svg
                          xmlns="http://www.w3.org/2000/svg"
                          fill="none"
                          viewBox="0 0 24 24"
                          strokeWidth={1.5}
                          stroke="currentColor"
                          className="min-w-[1.5rem] h-6 text-red-600"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                          />
                        </svg>
                      </button>
                      <li>
                        <span className="font-bold">{comment.author}</span> :{' '}
                        {comment.text}
                      </li>
                    </div>
                  ))}
                </ul>
              </div>
            );
          })
        : null}
    </div>
  );
};

export default CommentOverview;

因此,获取所有评论与添加const upToDateCommentQuery = trpcReact.getAllComments.useQuery()一样容易。
删除评论是通过添加const { mutate: deleteComment } = trpcReact.deleteCommentForBlog.useMutation然后在我的<button>的点击处理程序中完成的。

希望这很有帮助!
代码可以在my GitHub上找到。