NextJS14 app + Trpc + PayloadCMS + MongoDB 自定義伺服器搭建

彭小黑發表於2024-03-03

自定義伺服器啟動

相關依賴

  • dotenv 讀取 env 檔案資料
  • express node 框架

<details> <summary>基礎示例如下</summary>

// src/server/index.ts
import 'dotenv/config';
import express from 'express';
import chalk from 'chalk';

const port = Number(process.env.PORT) || 3000;
const app = express();
const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

const nextHandler = nextApp.getRequestHandler();
const start = async () => {
  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.all('*', (req, res) => {
      return nextHandler(req, res);
    });

    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

// package.json
// ...
// 這裡需要使用 esno 而不能使用 node. 因為 node 是 CommonJs 而我們程式碼中使用 es 規範
"dev": "esno src/server/index.ts"
// ...

配置 payload cms

個人理解 payload 和 cms 是兩個東西,只是使用 payload 時自動使用了 cms, 如果不使用 cms 的話就不管。
payload 主要是運算元據庫資料的,也有一些整合

相關依賴

  • @payloadcms/bundler-webpack
  • @payloadcms/db-mongodb
  • @payloadcms/richtext-slate
  • payload
開始前先抽離 nextApp nextHandler 函式,server 資料夾新建 next-utils.ts
import next from 'next';

const PORT = Number(process.env.PORT) || 3000;

// 建立 Next.js 應用例項
export const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

// 獲取 Next.js 請求處理器。用於處理傳入的 HTTP 請求,並根據 Next.js 應用的路由來響應這些請求。
export const nextRequestHandler = nextApp.getRequestHandler();
  1. 配置 config. 在 server 資料夾下建立 payload.config.ts

<details> <summary>基礎示例如下</summary>

/**
 * 配置 payload CMS 無頭內容管理系統
 * @author peng-xiao-shuai
 * @see https://www.youtube.com/watch?v=06g6YJ6JCJU&t=8070s
 */

import path from 'path';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

export default buildConfig({
  // 設定伺服器的 URL,從環境變數 NEXT_PUBLIC_SERVER_URL 獲取。
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || '',
  admin: {
    // 設定用於 Payload CMS 管理介面的打包工具,這裡使用了
    bundler: webpackBundler(),
    // 配置管理系統 Meta
    meta: {
      titleSuffix: 'Payload manage',
    },
  },
  // 定義路由,例如管理介面的路由。
  routes: {
    admin: '/admin',
  },
  // 設定富文字編輯器,這裡使用了 Slate 編輯器。
  editor: slateEditor({}),
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  // 配置請求的速率限制,這裡設定了最大值。
  rateLimit: {
    max: 2000,
  },

  // 下面 db 二選一。提示:如果是用 mongodb 沒有問題,使用 postgres 時存在問題,請更新依賴包
  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),

  db: postgresAdapter({
    pool: {
      connectionString: process.env.SUPABASE_URL,
    },
  }),
});

</details>

  1. 初始化 payload.init. 這裡初始化的時候還做了快取機制. 在 server 資料夾下建立 get-payload.ts

<details> <summary>基礎示例如下</summary>

/**
 * 處理快取機制。確保應用中多處需要使用 Payload 客戶端時不會重複初始化,提高效率。
 * @author peng-xiao-shuai
 */
import type { InitOptions } from 'payload/config';
import type { Payload } from 'payload';
import payload from 'payload';

// 使用 Node.js 的 global 物件來儲存快取。
let cached = (global as any).payload;

if (!cached) {
  cached = (global as any).payload = {
    client: null,
    promise: null,
  };
}

/**
 * 負責初始化 Payload 客戶端
 * @return {Promise<Payload>}
 */
export const getPayloadClient = async ({
  initOptions,
}: {
  initOptions: Partial<InitOptions>;
}): Promise<Payload> => {
  if (!process.env.PAYLOAD_SECRET) {
    throw new Error('PAYLOAD_SECRET is missing');
  }

  if (cached.client) {
    return cached.client;
  }

  if (!cached.promise) {
    // payload 初始化賦值
    cached.promise = payload.init({
      // email: {
      //   transport: transporter,
      //   fromAddress: 'hello@joshtriedcoding.com',
      //   fromName: 'DigitalHippo',
      // },
      secret: process.env.PAYLOAD_SECRET,
      local: initOptions?.express ? false : true,
      ...(initOptions || {}),
    });
  }

  try {
    cached.client = await cached.promise;
  } catch (e: unknown) {
    cached.promise = null;
    throw e;
  }

  return cached.client;
};

</details>

  1. index.ts 引入

<details> <summary>基礎示例如下</summary>

// 讀取環境變數
import 'dotenv/config';
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';

const port = Number(process.env.PORT) || 3000;
const app = express();
const start = async () => {
  // 獲取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use((req, res) => nextRequestHandler(req, res));

  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

  1. dev 執行配置. 安裝 cross-env nodemon. 設定 payload 配置檔案路徑. nodemon 啟動
// package.json
// ...
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts nodemon",
// ...
  1. nodemon 配置。根目錄建立 nodemon.json
{
  "watch": ["src/server/index.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

<!-- 先跑起來基礎示例後再閱讀 -->

payload 進階

  1. 定義型別。payload.config.ts 同級目錄新增 payload-types.ts

<details> <summary>示例如下</summary>

// payload.config.ts
// ...
typescript: {
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
}
// ...
// package.json 新增命令
// ...
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts payload generate:types",
// ...

執行 yarn generate:types 那麼會在 payload-types.ts 檔案中寫入基礎集合(Collection)型別

</details>

  1. 修改使用者 Collection 集合。collection
前提 server 資料夾下新增 collections 資料夾然後新增 Users.ts 檔案

<details> <summary>示例如下</summary>

// Users.ts
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  fields: [
    {
      // 定義地址
      name: 'address',
      required: true,
      type: 'text', // 貼別注意不同的型別有不同的資料 https://payloadcms.com/docs/fields/text
    },
    {
      name: 'points',
      hidden: true,
      defaultValue: 0,
      type: 'number',
    },
  ],
  access: {
    read: () => true,
    delete: () => false,
    create: ({ data, id, req }) => {
      // 設定管理系統不能新增
      return !req.headers.referer?.includes('/admin');
    },
    update: ({ data, id, req }) => {
      // 設定管理系統不能新增
      return !req.headers.referer?.includes('/admin');
    },
  },
};

還需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
// ...
collections: [Users],
admin: {
  user: 'users', // @see https://payloadcms.com/docs/admin/overview#the-admin-user-collection
  //  ...
},
// ...
  1. 新增在建立一個積分記錄集合。collections 資料夾下新增 PointsRecord.ts 檔案
/**
 * 積分記錄
 */
import { CollectionBeforeChangeHook, CollectionConfig } from 'payload/types';
import { PointsRecord as PointsRecordType } from '../payload-types';
import { getPayloadClient } from '../get-payload';

// @see https://payloadcms.com/docs/hooks/collections#beforechange
// https://payloadcms.com/docs/hooks/collections 中包含所有集合鉤子
const beforeChange: CollectionBeforeChangeHook<PointsRecordType> = async ({
  data,
  operate // 操作型別,這裡就不需要判斷了,因為只有修改前才會觸發這個鉤子,而修改又只有 update create delete 會觸發。update delete 又被我們禁用了所以只有 create 會觸發
}) => {
  // 獲取 payload
  const payload = await getPayloadClient();

  // 修改資料
  data.operateType = (data.count || 0) >= 0 ? 'added' : 'reduce';

  // 獲取當前使用者ID的資料
  const result = await payload.findByID({
    collection: 'users', // required
    id: data.userId as number, // required
  });

  // 修改使用者資料
  await payload.update({
    collection: 'users', // required
    id: data.userId as number, // required
    data: {
      ...result,
      points: (result.points || 0) + data.count!,
    },
  });

  return data;
};

export const PointsRecord: CollectionConfig = {
  slug: 'points-record', // 集合名稱,也就是資料庫表名
  fields: [
    {
      name: 'userId',
      type: 'relationship',
      required: true,
      relationTo: 'users',
    },
    {
      name: 'count',
      type: 'number',
      required: true,
    },
    {
      name: 'operateType',
      type: 'select',
      // 這裡隱藏避免在 cms 中顯示,因為 operateType 值是由判斷 count 生成。
      hidden: true,
      options: [
        {
          label: '增加',
          value: 'added',
        },
        {
          label: '減少',
          value: 'reduce',
        },
      ],
    },
  ],
  // 這個集合運算元據前的鉤子
  hooks: {
    beforeChange: [beforeChange],
  },
  access: {
    read: () => true,
    create: () => true,
    update: () => false,
    delete: () => false,
  },
};

</details>

同樣還需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
import { PointsRecord } from './collections/PointsRecord';
// ...
collections: [Users, PointsRecord],
// ...

安裝 trpc

相關依賴

  • @trpc/server
  • @trpc/client
  • @trpc/next
  • @trpc/react-query
  • @tanstack/react-query
  • zod 校驗
& 是在 next.config.js 資料夾中進行了配置
import path from 'path';
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
    // 設定別名
    config.resolve.alias['@'] = path.join(__dirname, 'src');
    config.resolve.alias['&'] = path.join(__dirname, 'src/server');

    // 重要: 返回修改後的配置
    return config;
  },
};

module.exports = nextConfig;
  1. server 資料夾下面建立 trpc 資料夾然後建立 trpc.ts 檔案。初始化 trpc

<details> <summary>基礎示例如下</summary>

import { initTRPC } from '@trpc/server';
import { ExpressContext } from '../';

// context 建立上下文
const t = initTRPC.context<ExpressContext>().create();

// Base router and procedure helpers
export const router = t.router;
export const procedure = t.procedure;

</details>

  1. 同級目錄新建 client.ts 檔案 trpc
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers';

export const trpc = createTRPCReact < AppRouter > {};
  1. app 資料夾下新增 components 資料夾在建立 Providers.tsx 檔案為客戶端元件

<details> <summary>基礎示例如下</summary>

'use client';

import { PropsWithChildren, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '&/trpc/client';
import { httpBatchLink } from '@trpc/client';

export const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${process.env.NEXT_PUBLIC_SERVER_URL}/api/trpc`,

          /**
           * @see https://trpc.io/docs/client/headers
           */
          // async headers() {
          //   return {
          //     authorization: getAuthCookie(),
          //   };
          // },

          /**
           * @see https://trpc.io/docs/client/cors
           */
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: 'include',
            });
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
};

</details>

  1. server/trpc 資料夾下建立 routers.ts 檔案 example

<details> <summary>基礎示例如下</summary>

import { procedure, router } from './trpc';
export const appRouter = router({
  hello: procedure
    .input(
      z
        .object({
          text: z.string().nullish(),
        })
        .nullish()
    )
    .query((opts) => {
      return {
        greeting: `hello ${opts.input?.text ?? 'world'}`,
      };
    }),
});
// export type definition of API
export type AppRouter = typeof appRouter;

</details>

  1. 任意 page.tsx 頁面 example

<details> <summary>基礎示例如下</summary>

// 'use client'; // 如果頁面有互動的話需要改成客戶端元件
import { trpc } from '&/trpc/client';

export function MyComponent() {
  // input is optional, so we don't have to pass second argument
  const helloNoArgs = trpc.hello.useQuery();
  const helloWithArgs = trpc.hello.useQuery({ text: 'client' });

  return (
    <div>
      <h1>Hello World Example</h1>
      <ul>
        <li>
          helloNoArgs ({helloNoArgs.status}):{' '}
          <pre>{JSON.stringify(helloNoArgs.data, null, 2)}</pre>
        </li>
        <li>
          helloWithArgs ({helloWithArgs.status}):{' '}
          <pre>{JSON.stringify(helloWithArgs.data, null, 2)}</pre>
        </li>
      </ul>
    </div>
  );
}

</details>

  1. index.ts 檔案引入

<details> <summary>基礎示例如下</summary>

import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';
import * as trpcExpress from '@trpc/server/adapters/express';
import { inferAsyncReturnType } from '@trpc/server';
import { config } from 'dotenv';
import { appRouter } from './trpc/routers';
config({ path: '.env.local' });
config({ path: '.env' });

const port = Number(process.env.PORT) || 3000;
const app = express();

const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({ req, res });

export type ExpressContext = inferAsyncReturnType<typeof createContext>;

const start = async () => {
  // 獲取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use(
    '/api/trpc',
    trpcExpress.createExpressMiddleware({
      router: appRouter,
      /**
       * @see https://trpc.io/docs/server/adapters/express#3-use-the-express-adapter
       * @example
        // 加了 返回了 req, res 之後可以在 trpc 路由中直接訪問
        import { createRouter } from '@trpc/server';
        import { z } from 'zod';

        const exampleRouter = createRouter<Context>()
          .query('exampleQuery', {
            input: z.string(),
            resolve({ input, ctx }) {
              // 直接訪問 req 和 res
              const userAgent = ctx.req.headers['user-agent'];
              ctx.res.status(200).json({ message: 'Hello ' + input });

              // 你的業務邏輯
              ...
            },
          });
       */
      createContext,
    })
  );
  app.use((req, res) => nextRequestHandler(req, res));

  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

報錯資訊

sharp module

ERROR (payload): Error: cannot connect to MongoDB. Details: queryTxt ETIMEOUT xxx.mongodb.net

  • 設定網路 Ipv4 DNS 伺服器為 114.114.114.144
  • 關閉防火牆
  • 設定 mongodb 可訪問的 ip0.0.0.0/0
    *

服務端

自定義伺服器啟動

相關依賴

  • dotenv 讀取 env 檔案資料
  • express node 框架

<details> <summary>基礎示例如下</summary>

// src/server/index.ts
import 'dotenv/config';
import express from 'express';
import chalk from 'chalk';

const port = Number(process.env.PORT) || 3000;
const app = express();
const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

const nextHandler = nextApp.getRequestHandler();
const start = async () => {
  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.all('*', (req, res) => {
      return nextHandler(req, res);
    });

    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

// package.json
// ...
// 這裡需要使用 esno 而不能使用 node. 因為 node 是 CommonJs 而我們程式碼中使用 es 規範
"dev": "esno src/server/index.ts"
// ...

配置 payload cms

個人理解 payload 和 cms 是兩個東西,只是使用 payload 時自動使用了 cms, 如果不使用 cms 的話就不管。
payload 主要是運算元據庫資料的,也有一些整合

相關依賴

  • @payloadcms/bundler-webpack
  • @payloadcms/db-mongodb
  • @payloadcms/richtext-slate
  • payload
開始前先抽離 nextApp nextHandler 函式,server 資料夾新建 next-utils.ts
import next from 'next';

const PORT = Number(process.env.PORT) || 3000;

// 建立 Next.js 應用例項
export const nextApp = next({
  dev: process.env.NODE_ENV !== 'production',
  port: PORT,
});

// 獲取 Next.js 請求處理器。用於處理傳入的 HTTP 請求,並根據 Next.js 應用的路由來響應這些請求。
export const nextRequestHandler = nextApp.getRequestHandler();
  1. 配置 config. 在 server 資料夾下建立 payload.config.ts

<details> <summary>基礎示例如下</summary>

/**
 * 配置 payload CMS 無頭內容管理系統
 * @author peng-xiao-shuai
 * @see https://www.youtube.com/watch?v=06g6YJ6JCJU&t=8070s
 */

import path from 'path';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

export default buildConfig({
  // 設定伺服器的 URL,從環境變數 NEXT_PUBLIC_SERVER_URL 獲取。
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || '',
  admin: {
    // 設定用於 Payload CMS 管理介面的打包工具,這裡使用了
    bundler: webpackBundler(),
    // 配置管理系統 Meta
    meta: {
      titleSuffix: 'Payload manage',
    },
  },
  // 定義路由,例如管理介面的路由。
  routes: {
    admin: '/admin',
  },
  // 設定富文字編輯器,這裡使用了 Slate 編輯器。
  editor: slateEditor({}),
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  // 配置請求的速率限制,這裡設定了最大值。
  rateLimit: {
    max: 2000,
  },

  // 下面 db 二選一。提示:如果是用 mongodb 沒有問題,使用 postgres 時存在問題,請更新依賴包
  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),

  db: postgresAdapter({
    pool: {
      connectionString: process.env.SUPABASE_URL,
    },
  }),
});

</details>

  1. 初始化 payload.init. 這裡初始化的時候還做了快取機制. 在 server 資料夾下建立 get-payload.ts

<details> <summary>基礎示例如下</summary>

/**
 * 處理快取機制。確保應用中多處需要使用 Payload 客戶端時不會重複初始化,提高效率。
 * @author peng-xiao-shuai
 */
import type { InitOptions } from 'payload/config';
import type { Payload } from 'payload';
import payload from 'payload';

// 使用 Node.js 的 global 物件來儲存快取。
let cached = (global as any).payload;

if (!cached) {
  cached = (global as any).payload = {
    client: null,
    promise: null,
  };
}

/**
 * 負責初始化 Payload 客戶端
 * @return {Promise<Payload>}
 */
export const getPayloadClient = async ({
  initOptions,
}: {
  initOptions: Partial<InitOptions>;
}): Promise<Payload> => {
  if (!process.env.PAYLOAD_SECRET) {
    throw new Error('PAYLOAD_SECRET is missing');
  }

  if (cached.client) {
    return cached.client;
  }

  if (!cached.promise) {
    // payload 初始化賦值
    cached.promise = payload.init({
      // email: {
      //   transport: transporter,
      //   fromAddress: 'hello@joshtriedcoding.com',
      //   fromName: 'DigitalHippo',
      // },
      secret: process.env.PAYLOAD_SECRET,
      local: initOptions?.express ? false : true,
      ...(initOptions || {}),
    });
  }

  try {
    cached.client = await cached.promise;
  } catch (e: unknown) {
    cached.promise = null;
    throw e;
  }

  return cached.client;
};

</details>

  1. index.ts 引入

<details> <summary>基礎示例如下</summary>

// 讀取環境變數
import 'dotenv/config';
import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';

const port = Number(process.env.PORT) || 3000;
const app = express();
const start = async () => {
  // 獲取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use((req, res) => nextRequestHandler(req, res));

  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

  1. dev 執行配置. 安裝 cross-env nodemon. 設定 payload 配置檔案路徑. nodemon 啟動
// package.json
// ...
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts nodemon",
// ...
  1. nodemon 配置。根目錄建立 nodemon.json

<!-- 我也不知道這些配置什麼意思配就行了 -->

{
  "watch": ["src/server/index.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

<!-- 先跑起來基礎示例後再閱讀 -->

payload 進階

  1. 定義型別。payload.config.ts 同級目錄新增 payload-types.ts

<details> <summary>示例如下</summary>

// payload.config.ts
// ...
typescript: {
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
}
// ...
// package.json 新增命令
// ...
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/server/payload.config.ts payload generate:types",
// ...

執行 yarn generate:types 那麼會在 payload-types.ts 檔案中寫入基礎集合(Collection)型別

</details>

  1. 修改使用者 Collection 集合。collection
前提 server 資料夾下新增 collections 資料夾然後新增 Users.ts 檔案

<details> <summary>示例如下</summary>

// Users.ts
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  fields: [
    {
      // 定義地址
      name: 'address',
      required: true,
      type: 'text', // 貼別注意不同的型別有不同的資料 https://payloadcms.com/docs/fields/text
    },
    {
      name: 'points',
      hidden: true,
      defaultValue: 0,
      type: 'number',
    },
  ],
  access: {
    read: () => true,
    delete: () => false,
    create: ({ data, id, req }) => {
      // 設定管理系統不能新增
      return !req.headers.referer?.includes('/admin');
    },
    update: ({ data, id, req }) => {
      // 設定管理系統不能新增
      return !req.headers.referer?.includes('/admin');
    },
  },
};

還需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
// ...
collections: [Users],
admin: {
  user: 'users', // @see https://payloadcms.com/docs/admin/overview#the-admin-user-collection
  //  ...
},
// ...
  1. 新增在建立一個積分記錄集合。collections 資料夾下新增 PointsRecord.ts 檔案
/**
 * 積分記錄
 */
import { CollectionBeforeChangeHook, CollectionConfig } from 'payload/types';
import { PointsRecord as PointsRecordType } from '../payload-types';
import { getPayloadClient } from '../get-payload';

// @see https://payloadcms.com/docs/hooks/collections#beforechange
// https://payloadcms.com/docs/hooks/collections 中包含所有集合鉤子
const beforeChange: CollectionBeforeChangeHook<PointsRecordType> = async ({
  data,
  operate // 操作型別,這裡就不需要判斷了,因為只有修改前才會觸發這個鉤子,而修改又只有 update create delete 會觸發。update delete 又被我們禁用了所以只有 create 會觸發
}) => {
  // 獲取 payload
  const payload = await getPayloadClient();

  // 修改資料
  data.operateType = (data.count || 0) >= 0 ? 'added' : 'reduce';

  // 獲取當前使用者ID的資料
  const result = await payload.findByID({
    collection: 'users', // required
    id: data.userId as number, // required
  });

  // 修改使用者資料
  await payload.update({
    collection: 'users', // required
    id: data.userId as number, // required
    data: {
      ...result,
      points: (result.points || 0) + data.count!,
    },
  });

  return data;
};

export const PointsRecord: CollectionConfig = {
  slug: 'points-record', // 集合名稱,也就是資料庫表名
  fields: [
    {
      name: 'userId',
      type: 'relationship',
      required: true,
      relationTo: 'users',
    },
    {
      name: 'count',
      type: 'number',
      required: true,
    },
    {
      name: 'operateType',
      type: 'select',
      // 這裡隱藏避免在 cms 中顯示,因為 operateType 值是由判斷 count 生成。
      hidden: true,
      options: [
        {
          label: '增加',
          value: 'added',
        },
        {
          label: '減少',
          value: 'reduce',
        },
      ],
    },
  ],
  // 這個集合運算元據前的鉤子
  hooks: {
    beforeChange: [beforeChange],
  },
  access: {
    read: () => true,
    create: () => true,
    update: () => false,
    delete: () => false,
  },
};

</details>

同樣還需要更改 payload.config.ts 中配置

import { Users } from './collections/Users';
import { PointsRecord } from './collections/PointsRecord';
// ...
collections: [Users, PointsRecord],
// ...

安裝 trpc

相關依賴

  • @trpc/server
  • @trpc/client
  • @trpc/next
  • @trpc/react-query
  • @tanstack/react-query
  • zod 校驗
& 是在 next.config.js 資料夾中進行了配置
import path from 'path';
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
    // 設定別名
    config.resolve.alias['@'] = path.join(__dirname, 'src');
    config.resolve.alias['&'] = path.join(__dirname, 'src/server');

    // 重要: 返回修改後的配置
    return config;
  },
};

module.exports = nextConfig;
  1. server 資料夾下面建立 trpc 資料夾然後建立 trpc.ts 檔案。初始化 trpc

<details> <summary>基礎示例如下</summary>

import { initTRPC } from '@trpc/server';
import { ExpressContext } from '../';

// context 建立上下文
const t = initTRPC.context<ExpressContext>().create();

// Base router and procedure helpers
export const router = t.router;
export const procedure = t.procedure;

</details>

  1. 同級目錄新建 client.ts 檔案 trpc
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers';

export const trpc = createTRPCReact < AppRouter > {};
  1. app 資料夾下新增 components 資料夾在建立 Providers.tsx 檔案為客戶端元件

<details> <summary>基礎示例如下</summary>

'use client';

import { PropsWithChildren, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '&/trpc/client';
import { httpBatchLink } from '@trpc/client';

export const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${process.env.NEXT_PUBLIC_SERVER_URL}/api/trpc`,

          /**
           * @see https://trpc.io/docs/client/headers
           */
          // async headers() {
          //   return {
          //     authorization: getAuthCookie(),
          //   };
          // },

          /**
           * @see https://trpc.io/docs/client/cors
           */
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: 'include',
            });
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
};

</details>

  1. server/trpc 資料夾下建立 routers.ts 檔案 example

<details> <summary>基礎示例如下</summary>

import { procedure, router } from './trpc';
export const appRouter = router({
  hello: procedure
    .input(
      z
        .object({
          text: z.string().nullish(),
        })
        .nullish()
    )
    .query((opts) => {
      return {
        greeting: `hello ${opts.input?.text ?? 'world'}`,
      };
    }),
});
// export type definition of API
export type AppRouter = typeof appRouter;

</details>

  1. 任意 page.tsx 頁面 example

<details> <summary>基礎示例如下</summary>

// 'use client'; // 如果頁面有互動的話需要改成客戶端元件
import { trpc } from '&/trpc/client';

export function MyComponent() {
  // input is optional, so we don't have to pass second argument
  const helloNoArgs = trpc.hello.useQuery();
  const helloWithArgs = trpc.hello.useQuery({ text: 'client' });

  return (
    <div>
      <h1>Hello World Example</h1>
      <ul>
        <li>
          helloNoArgs ({helloNoArgs.status}):{' '}
          <pre>{JSON.stringify(helloNoArgs.data, null, 2)}</pre>
        </li>
        <li>
          helloWithArgs ({helloWithArgs.status}):{' '}
          <pre>{JSON.stringify(helloWithArgs.data, null, 2)}</pre>
        </li>
      </ul>
    </div>
  );
}

</details>

  1. index.ts 檔案引入

<details> <summary>基礎示例如下</summary>

import express from 'express';
import { nextApp, nextRequestHandler } from './next-utils';
import { getPayloadClient } from './get-payload';
import * as trpcExpress from '@trpc/server/adapters/express';
import { inferAsyncReturnType } from '@trpc/server';
import { config } from 'dotenv';
import { appRouter } from './trpc/routers';
config({ path: '.env.local' });
config({ path: '.env' });

const port = Number(process.env.PORT) || 3000;
const app = express();

const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({ req, res });

export type ExpressContext = inferAsyncReturnType<typeof createContext>;

const start = async () => {
  // 獲取 payload
  const payload = await getPayloadClient({
    initOptions: {
      express: app,
      onInit: async (cms) => {
        console.log('\x1b[36m%s\x1b[0m', '✨✨Admin URL: ' + cms.getAdminURL());
      },
    },
  });

  app.use(
    '/api/trpc',
    trpcExpress.createExpressMiddleware({
      router: appRouter,
      /**
       * @see https://trpc.io/docs/server/adapters/express#3-use-the-express-adapter
       * @example
        // 加了 返回了 req, res 之後可以在 trpc 路由中直接訪問
        import { createRouter } from '@trpc/server';
        import { z } from 'zod';

        const exampleRouter = createRouter<Context>()
          .query('exampleQuery', {
            input: z.string(),
            resolve({ input, ctx }) {
              // 直接訪問 req 和 res
              const userAgent = ctx.req.headers['user-agent'];
              ctx.res.status(200).json({ message: 'Hello ' + input });

              // 你的業務邏輯
              ...
            },
          });
       */
      createContext,
    })
  );
  app.use((req, res) => nextRequestHandler(req, res));

  // 準備生成 .next 檔案
  nextApp.prepare().then(() => {
    app.listen(port, () => {
      console.log(
        '\x1b[36m%s\x1b[0m',
        `🎉🎉> Ready on http://localhost:${port}`
      );
    });
  });
};

start();

</details>

報錯資訊

sharp module

ERROR (payload): Error: cannot connect to MongoDB. Details: queryTxt ETIMEOUT xxx.mongodb.net

  • 設定網路 Ipv4 DNS 伺服器為 114.114.114.144
  • 關閉防火牆
  • 設定 mongodb 可訪問的 ip0.0.0.0/0
    image.png
  • 在引入 trpc 的頁面,需要將頁面改成客戶端元件

TypeError: (0 , react**WEBPACK\_IMPORTED\_MODULE\_3**.createContext) is not a function

  • 在引入 trpc 的頁面,需要將頁面改成客戶端元件

重啟服務端

  • server 資料夾下面只有 index.ts 檔案會被儲存會重新載入服務端,其他檔案更改需要再去 index.ts 重新儲存

或者將 nodemon.json 配置檔案更改。watch 中新增其他的檔案,儲存後自動重啟

{
  "watch": ["src/server/*.ts", "src/server/**/*.ts"],
  "exec": "ts-node --project tsconfig.server.json src/server/index.ts -- -I",
  "ext": "js ts",
  "stdin": false
}

示例倉庫地址:Github

聯絡郵箱:1612565136@qq.com

環境變數

克隆後根目錄新建 .env.local,寫入相應環境變數

# 資料庫連線地址
DATABASE_URL

# 郵件 API_KEY 需要去 https://resend.com/ 申請
RESEND_API_KEY

# 郵件 PUSHER_APP_ID NEXT_PUBLIC_PUSHER_APP_KEY PUSHER_APP_SECRET NEXT_PUBLIC_PUSHER_APP_CLUSTER 需要去 https://pusher.com/ 申請
PUSHER_APP_ID
NEXT_PUBLIC_PUSHER_APP_KEY
PUSHER_APP_SECRET
NEXT_PUBLIC_PUSHER_APP_CLUSTER

相關文章