Next14 app +Trpc 部署到 Vercel

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

next13 可以参考 trpc 文档 而且谷歌上已经有不少问题解答,但是目前 next14 app 只看到一个项目中有用到 Github 仓库,目前这个仓库中服务端的上下文获取存在问题,目前找到一个有用的可以看 Issus。目前 trpcnext14 app 的支持进度可以看 Issus

好的进入 正文

  1. 安装依赖包(这里我的依赖包版本是 10.43.1
base 复制代码
yarn add @trpc/serve @trpc/client @trpc/react-query zod
  1. 创建中间件 context.ts(我的trpc 相关文件的路径是 src/lib/trpc/)。这里我有用到 JWT 将用户信息挂载在上下文中
ts 复制代码
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>>;
  1. 创建 trpc.ts 文件存放实例
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;
  1. 创建 trpc 路由 auth-router.ts points-router.ts routers.ts

特别注意,在服务端组件中请求时没有 ctx

ts 复制代码
// 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;
    }),
});
ts 复制代码
// 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,
        });
      }
    }),
});
ts 复制代码
// 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
ts 复制代码
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from './routers';

export const trpc = createTRPCReact<AppRouter>({});
  1. 创建 trpc 上下文组件
tsx 复制代码
'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 文件中引入

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>
  );
}
  1. page.tsx 文件中调用 trpc 路由请求
tsx 复制代码
'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 路由请求

  1. 定义服务端请求,创建 serverClient.ts
ts 复制代码
import { appRouter } from './routers';
import { createCallerFactory } from './trpc';

const createCaller = createCallerFactory(appRouter);

// 这里目前博主拿不到 req 和可写的 resHeaders
export const serverClient = createCaller({});
  1. 服务端组件调用
tsx 复制代码
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

示例仓库:暂无(后续补上)

相关推荐
铁皮饭盒9 小时前
成为AI全栈 - 第3课:路由 RESTful Elysia 状态码 设计规范
前端·后端·全栈
暗不需求12 小时前
# 深入 React Todos:从零实现一个状态提升与本地持久化的待办应用
javascript·react.js·全栈
骑自行车的码农12 小时前
数据的源头 —— JSX
react.js
时光足迹13 小时前
Tiptap 简单编辑器模版
前端·javascript·react.js
时光足迹14 小时前
Tiptap编辑器
前端·javascript·react.js
时光足迹14 小时前
电子书阅读器之笔记高亮(跨段处理)
前端·javascript·react.js
空中海16 小时前
03 渲染机制、性能优化与现代 React
javascript·react.js·性能优化
donecoding19 小时前
nrm、corepack、npm registry 三者的爱恨情仇
前端·node.js·前端工程化
openKaka_19 小时前
为什么 React 18 之后使用 createRoot,而不是 ReactDOM.render
前端·javascript·react.js