用Pnpm+Turbo实现一个前后端共享api的Todos项目

简介

用pnpm、turbo搭建monorepo项目,服务端nestjs、客户端vue,通过ts-rest共享前、后端Typescript Api类型。

执行turbo命令,比如turbo run dev,需要workspace的包内都包含该命令,比如:nestjs的开发命令为start:dev,vite项目为dev,那么就会只运行vite项目内的dev

1. 新建项目

bash 复制代码
# 新建文件夹
mkdir todos-monorepo

# 进入目录
cd todos-monorepo

# 初始化package.json
pnpm init 

# 新建pnpm-workspace.yaml
touch pnpm-workspace.yaml

# 新建turbo.json
touch turbo.json

# 创建apps和packages文件夹,存放app和共享包
mkdir apps
mkdir packages
  • package.json配置
json 复制代码
// todos-monorepo/package.json
{
  "name": "todos-monorepo",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "build:release": "turbo run build --no-cache"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "turbo": "^1.13.2"
  }
}
  • pnpm-workspace.yaml配置
yaml 复制代码
# todos-monorepo/pnpm-workspace.yaml
packages:
  - 'apps/**'
  - 'packages/*'
  • turbo.json配置
json 复制代码
// todos-monorepo/turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

2. 依赖安装和初始化项目

2.1 安装依赖

bash 复制代码
# todos-monorepo根目录安装
pnpm i turbo -D

2.2 初始化服务端nestjs项目和客户端ui项目

bash 复制代码
# 进入apps目录
cd apps

# 用nest-cli初始化api项目
nest new todo-api

# 用pnpm初始化基于vite的vue+ts项目
pnpm create vite
  • 此时项目结构如下图

2.3 建立packages内shared-api包并配置

bash 复制代码
# 切换到packages目录
cd packages

# 创建共享api包
mkdir shared-api

# 进入shared-api文件夹
cd shared-api

# 创建package.json文件,配置在下面
pnpm init 

# 创建tsconfig.json,配置在下面
touch tsconfig.json

# 创建src内的index.ts
mkdir src && touch src/index.ts文件

# 安装shared-api的依赖
pnpm add @ts-rest/core typescript zod
  • package.json配置
json 复制代码
// todos-monorepo/packages/shared-api/package.json
{
  "name": "shared-api",
  "version": "1.0.0",
  "description": "",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "dev": "tsc --watch",
    "build": "tsc"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@ts-rest/core": "^3.44.0",
    "typescript": "^5.4.5",
    "zod": "^3.22.4"
  }
}
  • tsconfig.json配置
json 复制代码
// todos-monorepo/packages/shared-api/tsconfig.json
{
  "compilerOptions": {
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "module": "CommonJS",
    "preserveWatchOutput": true,
    "skipLibCheck": true,
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true
  },
  "exclude": ["node_modules", "./dist/**/*"]
}
typescript 复制代码
// todos-monorepo/packages/shared-api/src/index.ts
import { initContract } from "@ts-rest/core";
import { z } from "zod";

const c = initContract();

export const TodoSchema = z.object({
  id: z.number(),
  title: z.string(),
  description: z.string(),
  completed: z.boolean(),
});

export type Todo = z.infer<typeof TodoSchema>;

export const contract = c.router(
  {
    todos: {
      create: {
        method: "POST",
        path: "/todos",
        body: TodoSchema.omit({ id: true }),
        responses: {
          201: TodoSchema,
        },
      },
      getAll: {
        method: "GET",
        path: "/todos",
        query: z.object({
          title: z.string().optional(),
        }),
        responses: {
          200: TodoSchema.array(),
        },
      },
      getOne: {
        method: "GET",
        path: "/todos/:id",
        pathParams: z.object({
          id: z.coerce.number(),
        }),
        responses: {
          200: TodoSchema,
          404: z.object({
            message: z.string(),
          }),
        },
      },
      update: {
        method: "PATCH",
        path: "/todos/:id",
        pathParams: z.object({
          id: z.coerce.number(),
        }),
        body: TodoSchema.omit({ id: true }).partial(),
        responses: {
          200: TodoSchema,
          404: z.object({
            message: z.string(),
          }),
        },
      },
      remove: {
        method: "DELETE",
        path: "/todos/:id",
        pathParams: z.object({
          id: z.coerce.number(),
        }),
        body: z.any(),
        responses: {
          204: z.object({}),
          404: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
  {
    pathPrefix: "/api",
    strictStatusCodes: true,
  }
);

export type Contract = typeof contract;

export type UpdateTodoDto = z.infer<Contract["todos"]["update"]["body"]>;

2.4 配置todo-api

进入todos-monorepo/apps/todo-api目录,修改start:dev命令为dev,和根目录package.json内配置的turbo run dev命令保持一致。

json 复制代码
// todos-monorepo/apps/todo-api/package.json
"scripts": {
     // 主要修改地方
    "dev": "nest start --watch"
     // 其它部分
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
 },
  • 此时在终端,切换到根目录执行pnpm run dev会看到下图所示,两个项目都一起运行起来了:

2.5 给todo-api安装shared-api包

bash 复制代码
# todos-monorepo目录执行
pnpm add shared-api --filter todo-api

安装正常,会看到package.json内安装的shared-api依赖

2.6 用nest-cli生成todos模块

bash 复制代码
# todos-monorepo/apps/todo-api目录,生成todos模块目录
nest g res todos

# todos-monorepo/apps/todo-api目录,安装依赖包
pnpm add @ts-rest/nest @ts-rest/core @ts-rest/open-api @nestjs/swagger
  • todos.controller.ts代码
typescript 复制代码
// todos-monorepo/apps/todo-api/todos/todos.controller.ts
import { Controller } from '@nestjs/common';
import { TodosService } from './todos.service';
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
import '@ts-rest/core';
import { contract } from 'shared-api';

@Controller()
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @TsRestHandler(contract.todos)
  async handler() {
    return tsRestHandler(contract.todos, {
      create: async ({ body }) => {
        return {
          status: 201,
          body: this.todosService.create(body),
        };
      },
      getAll: async ({ query: { title } }) => {
        return {
          status: 200,
          body: this.todosService.getAll(title),
        };
      },
      getOne: async ({ params: { id } }) => {
        const item = await this.todosService.getOne(id);
        if (!item) {
          return {
            status: 404,
            body: {
              message: '没有item',
            },
          };
        }
        return {
          status: 200,
          body: item,
        };
      },
      update: async ({ params: { id }, body }) => {
        const item = await this.todosService.update(id, body);
        if (!item) {
          return {
            status: 404,
            body: {
              message: '没有item',
            },
          };
        }
        return {
          status: 200,
          body: item,
        };
      },
      remove: async ({ params: { id } }) => {
        const item = await this.todosService.remove(id);
        if (!item) {
          return {
            status: 404,
            body: {
              message: '没有item',
            },
          };
        }
        return {
          status: 204,
          body: item,
        };
      },
    });
  }
}
  • todos.service.ts代码
typescript 复制代码
// todos-monorepo/apps/todo-api/todos/todos.service.ts
import { Injectable } from '@nestjs/common';
import { Todo, UpdateTodoDto } from 'shared-api';

@Injectable()
export class TodosService {
  todos: Todo[] = [
    {
      id: 1,
      title: 'Todo 1',
      description: 'Todo 1 description',
      completed: false,
    },
    {
      id: 2,
      title: 'Todo 2',
      description: 'Todo 2 description',
      completed: false,
    },
    {
      id: 3,
      title: 'Todo 3',
      description: 'Todo 3 description',
      completed: false,
    },
  ];

  create(todo: Omit<Todo, 'id'>) {
    const newTodo = {
      ...todo,
      id: Date.now(),
    };
    this.todos.push(newTodo);
    return newTodo;
  }

  getAll(title?: string) {
    if (title) {
      return this.todos.filter((todo) =>
        todo.title.toLowerCase().includes(title.toLowerCase()),
      );
    }
    return this.todos;
  }

  getOne(id: number) {
    return this.todos.find((todo) => todo.id === id);
  }

  // update(id: number, dto: Partial<Todo>) {
  update(id: number, dto: UpdateTodoDto) {
    const index = this.todos.findIndex((todo) => todo.id === id);
    if (index === -1) {
      return null;
    }
    this.todos[index] = { ...this.todos[index], ...dto };
    return this.todos[index];
  }

  remove(id: number) {
    const index = this.todos.findIndex((todo) => todo.id === id);
    if (index === -1) {
      return null;
    }
    this.todos = this.todos.filter((todo) => todo.id !== id);
    return {};
  }
}
  • main.ts代码
typescript 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

import { SwaggerModule } from '@nestjs/swagger';
import { generateOpenApi } from '@ts-rest/open-api';
import { contract } from 'shared-api';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const openApiDocument = generateOpenApi(contract, {
    info: {
      title: 'Todos API',
      version: '1.0.0',
    },
  });
  SwaggerModule.setup('openapi', app, openApiDocument);

  await app.listen(3000);
  
  console.log(`服务端运行在 http://localhost:3000`);
}
bootstrap();
  • 此时打开http://localhost:3000/openapi地址会看到自动配置好的swagger文档

提示:这里没有用@nestjs/swaggerDocumentBuilder创建

3. 继续开发todo-ui

未完待续...

相关链接

turbo.build/repo/docs

pnpm.io/zh/

www.youtube.com/watch?v=aEe...

相关推荐
y先森5 小时前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy5 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189115 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿6 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡7 小时前
commitlint校验git提交信息
前端
虾球xz7 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇8 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒8 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员8 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐8 小时前
前端图像处理(一)
前端