動態

詳情 返回 返回

Next14 app +Trpc 部署到 Vercel - 動態 詳情

本文使用了 MongoDB, 還沒有集成的可以看一下上篇文章

next13 可以參考 trpc 文檔 而且谷歌上已經有不少問題解答,但是目前 next14 app 只看到一個項目中有用到 Github 倉庫,目前這個倉庫中服務端的上下文獲取存在問題,目前找到一個有用的可以看 Issus。目前 trpcnext14 app 的支持進度可以看 Issus

好的進入 正文

  1. 安裝依賴包(這裏我的依賴包版本是 10.43.1

    yarn add @trpc/serve @trpc/client @trpc/react-query zod
  2. 創建中間件 context.ts(我的trpc 相關文件的路徑是 src/lib/trpc/)。這裏我有用到 JWT 將用户信息掛載在上下文中

    import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
    import Jwt from 'jsonwebtoken';
    
    type Opts = Partial<FetchCreateContextFnOptions>;
    /**
     * 創建上下文 服務端組件中沒有req resHeaders
     * @see https://trpc.io/docs/server/adapters/fetch#create-the-context
     */
    export function createContext(opts?: Opts): Opts & {
      userInfo?: Jwt.JwtPayload;
    } {
      const userInfo = {};
      return { ...(opts || {}), userInfo };
    }
    
    export type Context = Awaited<ReturnType<typeof createContext>>;
  3. 創建 trpc.ts 文件存放實例

    /**
     * @see https://trpc.io/docs/router
     * @see https://trpc.io/docs/procedures
     */
    import { TRPCError, initTRPC } from '@trpc/server';
    import { Context } from './context';
    import { parseCookies } from '@/utils/util'; // 格式化 cookie
    import jwt from 'jsonwebtoken';
    
    // 可以自行放在 utils/util 文件中
    // export function parseCookies(cookieString: string) {
    //   const list: { [key: string]: string } = {};
    //   cookieString &&
    //     cookieString.split(';').forEach((cookie) => {
    //       const parts: string[] = cookie.split('=');
    //       if (parts.length) {
    //         list[parts.shift()!.trim()] = decodeURI(parts.join('='));
    //       }
    //     });
    //   return list;
    // }
    
    const t = initTRPC.context<Context>().create();
    
    // 鑑權中間件
    const authMiddleware = t.middleware(({ ctx, next }) => {
      const token = parseCookies(ctx.req?.headers.get('cookie') || '').token;
    
      const data = jwt.verify(token, process.env.JWT_SECRET!);
    
      if (typeof data == 'string') {
     throw new TRPCError({ code: 'UNAUTHORIZED' });
      }
      return next({
     ctx: {
       ...ctx,
       userInfo: data,
     },
      });
    });
    
    /**
     * 需要鑑權的路由
     * @see https://trpc.nodejs.cn/docs/server/middlewares#authorization
     */
    export const authProcedure = t.procedure.use(
      authMiddleware.unstable_pipe(({ ctx, next }) => {
     return next({
       ctx,
     });
      })
    );
    
    /**
     * Unprotected procedure
     **/
    export const publicProcedure = t.procedure;
    
    export const router = t.router;
    
    // 創建服務端調用在示例倉庫中使用的是 createCaller, 但是 createCaller 在 trpc v11 中已經廢棄
    // @see https://trpc.io/docs/server/server-side-calls#create-caller
    export const createCallerFactory = t.createCallerFactory;
  4. 創建 trpc 路由 auth-router.ts points-router.ts routers.ts

特別注意,在服務端組件中請求時沒有 ctx

// auth-router.ts
// auth-router.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
import { TRPCError } from '@trpc/server';
// 數據庫設置 db.ts 放在 lib/db.ts
// db.ts 內容查看連接 https://juejin.cn/post/7341669201008918565 正題中第 2 點
import clientPromise from '../db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

export const authRouter = router({
  signIn: publicProcedure
    .input(
      z.object({
        name: z.string(),
        pwd: z.string(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const { resHeaders } = ctx;
      const { name, pwd } = input;
      const client = await clientPromise;
      const collection = client.db('test').collection('users');
      try {
        const user = await collection.findOne({
          name: name,
        });

        if (user) {
          // 判斷是否有用户存在, 存在直接登錄
          const isValid = await bcrypt.compare(pwd, user.password);

          if (!isValid) {
            // 返回 401
            return new TRPCError({
              code: 'FORBIDDEN',
              message: 'Invalid credentials',
            });
          }

          const token = jwt.sign(
            { userId: user._id, name: user.name },
            process.env.JWT_SECRET!, // 這裏需要再環境變量中定義
            { expiresIn: '12h' }
          );
          // 設置cookie
          resHeaders?.set('Set-Cookie', 'token=' + token);

          return {
            code: 200,
            data: token,
            success: true,
          };
        }
        // 註冊邏輯
        // 加密用户密碼
        const hashedPassword = await bcrypt.hash(pwd, 12);

        // 存儲用户
        const result = await collection.insertOne({
          name: name,
          points: 0, // 積分
          password: hashedPassword,
          createdAt: new Date(),
          updatedAt: new Date(),
        });

        const token = jwt.sign(
          { userId: result.insertedId, name: name },
          process.env.JWT_SECRET!,
          { expiresIn: '12h' }
        );
        resHeaders?.set('Set-Cookie', 'token=' + token);

        return {
          code: 200,
          data: token,
          success: true,
        };
      } catch (error: any) {
        // console.log(error);
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: error.message,
        });
      }
    }),

  login: publicProcedure
    .input(
      z.object({
        name: z.string(),
        pwd: z.string(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const { resHeaders } = ctx;
      const client = await clientPromise;
      const collection = client.db('test').collection('users');

      const { name, pwd } = input;
      const user = await collection.findOne({
        name: name,
      });

      // 比較恢復的地址和預期的地址
      try {
        if (user) {
          const isValid = await bcrypt.compare(pwd, user.password);

          if (!isValid) {
            return new TRPCError({
              code: 'FORBIDDEN',
              message: 'Invalid credentials',
            });
          }

          const token = jwt.sign(
            { userId: user._id, name: name },
            process.env.JWT_SECRET!,
            { expiresIn: '12h' }
          );
          resHeaders?.set('Set-Cookie', 'token=' + token);

          return {
            code: 200,
            data: token,
            success: true,
          };
        } else {
          throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: 'User information not found',
          });
        }
      } catch (error: any) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: error.message,
        });
      }
    }),
  // 測試服務端的 trpc 請求
  hello: publicProcedure
    .input(
      z.object({
        name: z.string(),
      })
    )
    .mutation(async ({ input, ctx }) => {
       // ctx 是沒有數據的
      return input.name;
    }),
});
// points-router.ts
import { z } from 'zod';
import { authProcedure, router } from './trpc';
import { TRPCError } from '@trpc/server';
import clientPromise from '../db';
import { ObjectId } from 'mongodb';

export const PointsRouter = router({
  // 這裏使用的是 authProcedure 中間件路由,需要有攜帶 token 且鑑權通過才會進入路由,否則返回 401 
  added: authProcedure
    .input(
      z.object({
        count: z.number(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const { userInfo } = ctx;

      const client = await clientPromise;
      const collection = client.db('test').collection('points-records');
      const userCollection = client.db('test').collection('users');

      try {
        // 查詢數據
        const result = await userCollection.findOne({
          _id: new ObjectId(userInfo.userId),
        });

        // 添加積分記錄數據
        await collection.insertOne({
          userId: userInfo.userId,
          count: input.count,
          points: (result?.points || 0) + input.count!,
          operateType: (input.count || 0) >= 0 ? 'added' : 'reduce',
          createdAt: new Date(),
          updatedAt: new Date(),
        });

        // 修改用户積分數據
        await userCollection.updateOne(
          { _id: new ObjectId(userInfo.userId) },
          {
            $set: {
              points: (result?.points || 0) + input.count!,
              updatedAt: new Date(),
            },
          }
        );

        return {
          code: 200,
          data: {},
          success: true,
        };
      } catch (error: any) {
        console.log(error.message);

        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: error.message,
        });
      }
    }),
});
// routers.ts
import { router } from './trpc';
import { authRouter } from './auth-router';
import { PointsRouter } from './points-router';

export const appRouter = router({
  authRouter,
  PointsRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

至此路由文件已經定義完成。

  1. 創建客户端 trpc 請求, client.ts

    import { createTRPCReact } from '@trpc/react-query';
    import { type AppRouter } from './routers';
    
    export const trpc = createTRPCReact<AppRouter>({});
  2. 創建 trpc 上下文組件

    'use client';
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { httpBatchLink } from '@trpc/client';
    import React, { useState } from 'react';
    import { trpc } from '@/lib/trpc/client';
    
    function getBaseUrl() {
      if (typeof window !== 'undefined') {
     // In the browser, we return a relative URL
     return '';
      }
      // When rendering on the server, we return an absolute URL
    
      // reference for vercel.com
      if (process.env.VERCEL_URL) {
     return `https://${process.env.VERCEL_URL}`;
      }
    
      // assume localhost
      return `http://localhost:${process.env.PORT ?? 3000}`;
    }
    
    export function TrpcProviders({ children }: { children: React.ReactNode }) {
      const [queryClient] = useState(() => new QueryClient({}));
      const [trpcClient] = useState(() =>
     trpc.createClient({
       links: [
         httpBatchLink({
           url: getBaseUrl() + '/api/trpc',
         }),
       ],
     })
      );
      return (
     <trpc.Provider client={trpcClient} queryClient={queryClient}>
       <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
     </trpc.Provider>
      );
    }

    layout.tsx 文件中引入

    // layout.tsx
    import { TrpcProviders } from 'xxx'
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
     <html lang="en">
       <body>
         <TrpcProviders>{children}</TrpcProviders>
       </body>
     </html>
      );
    }
  3. page.tsx 文件中調用 trpc 路由請求

    'use client';
    import { useEffect } from 'react';
    import { trpc } from '@/lib/trpc/client';
    
    export function PageHome() {
     const { mutate } = trpc.authRouter.signIn.useMutation();
    
     useEffect(() => {
         mutate({
           name: 'pxs',
           pwd: 'pxs',
         })
     }, []);
     
     return (<div>1111</div>)
    }

    服務端組件使用 trpc 路由請求

  4. 定義服務端請求,創建 serverClient.ts

    import { appRouter } from './routers';
    import { createCallerFactory } from './trpc';
    
    const createCaller = createCallerFactory(appRouter);
    
    // 這裏目前博主拿不到 req 和可寫的 resHeaders
    export const serverClient = createCaller({});
  5. 服務端組件調用

    import { serverClient } from '@/lib/trpc/serverClient';
    
    export default async function ServerPage() {
      const res = await serverClient.authRouter.hello({ name: 'pxs' });
    
      return <div>{JSON.stringify(res)}</div>;
    }

至此 next14 app 使用 trpc 已完成。

聯繫:1612565136@qq.com

示例倉庫:Github

user avatar savokiss 頭像 zxl20070701 頭像 zhuifengdekukafei 頭像 code500g 頭像 zohocrm 頭像 pulsgarney 頭像 fkcaikengren 頭像 kanshouji 頭像 maililuo 頭像 yangy5hqv 頭像 minghuajiwu 頭像 kanjianliao 頭像
點贊 21 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.