从 0 到 1:AI Todos 项目 Day1 实战——用 pnpm + Turbo 搭建可迭代的 Monorepo 基线

0 阅读6分钟

Day 1 基础工程搭建(Step 1-19)执行笔记

微信图片_20260501114927_1316_367.png

Step 1:修正 pnpm-workspace.yaml

  • 将 workspace 配置修正为:
    • packages:
    • apps/*
    • packages/*
  • 修复了初始版本中缺少 packages: 根字段、packages/*. 多余 . 的问题。
  • 结果:pnpm 能正确识别 appspackages 下的工作区包。
packages:
  - apps/*
  - packages/*

Step 2:创建 Monorepo 目录骨架

  • 创建了核心目录结构:
    • apps/web
    • apps/admin
    • apps/server
    • packages/shared
    • packages/api-sdk
    • packages/store
  • 结果:Monorepo 目录基础成型,可继续分包初始化。

image-20260428200205298.png

image-20260428200215745.png

Step 3:创建根目录 .gitignore

  • 添加了常见忽略项:node_modulesdistbuild.turbocoverage.env**.log
  • 结果:避免依赖与构建产物进入版本库,保持仓库整洁。
node_modules
dist
build
.turbo
coverage
.env
.env.*
*.log

Step 4:改造根 package.json

  • 设置 private: true,明确这是 Monorepo 根工程。
  • 配置统一脚本:devbuildlinttype-checkformat
  • dev 脚本更新为 turbo run dev(去除已弃用的 --parallel)。
  • 保留 packageManager: pnpm@10.31.0,统一团队工具版本。
{
  "name": "ai-todos",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check",
    "format": "prettier . --write"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.31.0",
  "devDependencies": {
    "eslint": "^10.2.1",
    "prettier": "^3.8.3",
    "turbo": "^2.9.6",
    "typescript": "^6.0.3"
  }
}

Step 5:安装根开发依赖

  • 在 workspace 根安装开发依赖:turbotypescripteslintprettier
  • 使用 pnpm add -Dw ... 显式声明安装到 workspace root。
  • 结果:依赖写入根 devDependencies,并生成/更新 pnpm-lock.yaml
pnpm add -Dw turbo typescript eslint prettier

Step 6:创建 turbo.json

  • 配置了基础任务编排:devbuildlinttype-check
  • 关键规则:
    • dev 为常驻任务(persistent)且不缓存(cache: false)。
    • build 依赖上游 ^build 并声明输出目录(dist/**build/**)。
    • linttype-check 依赖上游同名任务,保证执行顺序。
  • 因 IDE 信任策略,移除了 $schema,不影响 turbo 实际执行。
{
    "tasks": {
        "dev": {
            "cache": false,
            "persistent": true
        },
        "build": {
            "dependsOn": [
                "^build"
            ],
            "outputs": [
                "dist/**",
                "build/**"
            ]
        },
        "lint": {
            "dependsOn": [
                "^lint"
            ]
        },
        "type-check": {
            "dependsOn": [
                "^type-check"
            ]
        }
    }
}

Step 7:准备 TypeScript 根配置

  • 新建 tsconfig.base.json 作为全项目共享 TS 配置。
  • 统一了关键编译选项:strictmodulemoduleResolutionresolveJsonModuleskipLibCheck 等。
  • 初始配置中包含 baseUrl,后续在 TS 6 下触发弃用报错后已移除,恢复 type-check 正常通过。
  • 结果:apps/*packages/* 可以通过 extends 复用同一套 TS 规范。
{
    "compilerOptions": {
        "target": "ES2022",
        "lib": [
            "ES2022",
            "DOM",
            "DOM.Iterable"
        ],
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "strict": true,
        "skipLibCheck": true,
        "resolveJsonModule": true,
        "esModuleInterop": true,
        "isolatedModules": true,
        "noUncheckedIndexedAccess": true
    }
}

Step 8:初始化 apps/web

  • 完成 web 最小可运行工程:package.jsontsconfig.jsonvite.config.tsindex.htmlsrc/App.tsxsrc/main.tsx
  • 脚本包含:devbuildtype-check
  • 接入依赖:reactreact-domvite@vitejs/plugin-react@types/react@types/react-dom
  • 结果:@aitodos/web 可独立启动并通过类型检查。

package.json:

{
    "name": "@aitodos/web",
    "private": true,
    "version": "1.0.0",
    "scripts": {
        "dev": "vite",
        "build": "vite build",
        "type-check": "tsc --noEmit"
    },
    "dependencies": {
        "react": "^19.2.5",
        "react-dom": "^19.2.5"
    },
    "devDependencies": {
        "@types/react": "^19.2.14",
        "@types/react-dom": "^19.2.3",
        "@vitejs/plugin-react": "^6.0.1",
        "vite": "^8.0.10"
    }
}

tsconfig.json:

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "jsx": "react-jsx",
        "types": [
            "vite/client"
        ],
        "noEmit": true
    },
    "include": [
        "src",
        "vite.config.ts"
    ]
}

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
    plugins: [react()]
});

index.html:

<!doctype html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AiTodos Web</title>
</head>

<body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
</body>

</html>

src/App.tsx:

export default function App() {
    return <h1>AiTods Web is running</h1>;
}

src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

Step 9:初始化 apps/admin

  • 完成 admin 最小可运行工程(与 web 同构):配置文件与入口文件已齐全。
  • 脚本包含:devbuildtype-check
  • 接入依赖:React + Vite 相关依赖。
  • 结果:@aitodos/admin 可独立启动并通过类型检查。

配置参考apps/web,其中:package.json的name、index.html的title、src/App.tsx要按需修改

Step 10:初始化 apps/server

  • 完成 server 最小 Fastify 服务:package.jsontsconfig.jsontsconfig.build.jsonsrc/index.ts
  • 脚本包含:devtsx watch)、buildstarttype-check
  • 提供 /health 健康检查接口,默认监听 3000
  • 结果:@aitodos/server 可启动并返回健康检查响应。

package.json:

{
    "name": "@aitodos/server",
    "private": true,
    "version": "1.0.0",
    "scripts": {
        "dev": "tsx watch src/index.ts",
        "build": "tsc -p tsconfig.build.json",
        "start": "node dist/index.js",
        "type-check": "tsc --noEmit"
    },
    "dependencies": {
        "@aitodos/shared": "workspace:*",
        "fastify": "^5.8.5"
    },
    "devDependencies": {
        "@types/node": "^24.7.2",
        "tsx": "^4.20.6"
    }
}

tsconfig.json:

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "moduleResolution": "NodeNext",
        "module": "NodeNext",
        "types": [
            "node"
        ],
        "noEmit": true
    },
    "include": [
        "src"
    ]
}

tsconfig.build.json:

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "moduleResolution": "NodeNext",
        "module": "NodeNext",
        "types": [
            "node"
        ],
        "noEmit": true
    },
    "include": [
        "src"
    ]
}

src/index.ts:

import Fastify from "fastify";

const app = Fastify({ logger: true });
const port = Number(process.env.PORT ?? 3000);

app.get("/health", async () => {
    return { ok: true, service: "server"};
});

const start = async () => {
    try {
        await app.listen({ port, host: "0.0.0.0" });
        app.log.info(`Server running at http://localhost:${port}`);
    } catch (error) {
        app.log.error(error);
        process.exit(1);
    }
};

start();

Step 11:初始化 packages/shared 并完成跨包引用

  • 创建共享包文件:package.jsontsconfig.jsontsconfig.build.jsonsrc/index.ts
  • 导出共享内容:APP_NAMETodoStatusTodoItemnormalizeTodoTitle
  • webserver 中分别添加依赖:@aitodos/shared: workspace:*
  • 结果:webserver 可成功 import 共享包。

package.json:

{
    "name": "@aitodos/shared",
    "private": true,
    "version": "1.0.0",
    "type": "module",
    "exports": {
        ".": "./src/index.ts"
    },
    "scripts": {
        "build": "tsc -p tsconfig.build.json",
        "type-check": "tsc --noEmit"
    }
}

tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true
  },
  "include": ["src"]
}

tsconfig.build.json:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false,
        "outDir": "dist",
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true
    },
    "include": [
        "src"
    ]
}

src/index.ts:

export const APP_NAME = "AiTodos";

export type TodoStatus = "todo" | "doing" | "done";

export interface TodoItem {
    id: string;
    title: string;
    status: TodoStatus;
}

export const normalizeTodoTitle = (title: string) => title.trim();

在shell中执行:

pnpm add @aitodos/shared@workspace:* --filter @aitodos/web
pnpm add @aitodos/shared@workspace:* --filter @aitodos/admin

web/src/App.tsx:

import { APP_NAME } from "@aitodos/shared";
import { createApiClient } from "@aitodos/api-sdk";

const api = createApiClient("http://localhost:3000");
void api;

export default function App() {
    return <h1>{APP_NAME} Web is running</h1>;
}

server/src/index.ts:

import Fastify from "fastify";
import { APP_NAME } from "@aitodos/shared";

const app = Fastify({ logger: true });
const port = Number(process.env.PORT ?? 3000);

app.get("/health", async () => {
    return { ok: true, service: "server", app: APP_NAME };
});

const start = async () => {
    try {
        await app.listen({ port, host: "0.0.0.0" });
        app.log.info(`Server running at http://localhost:${port}`);
    } catch (error) {
        app.log.error(error);
        process.exit(1);
    }
};

start();

Step 12:初始化 packages/api-sdk

  • 创建 SDK 包文件:package.jsontsconfig.jsontsconfig.build.jsonsrc/index.ts
  • 实现最小客户端:createApiClient(baseUrl) + getHealth()
  • webadmin 中添加依赖:@aitodos/api-sdk: workspace:*
  • 结果:前端应用可引用 api-sdk,为后续接口联调预留入口。

package.json:

{
    "name": "@aitodos/api-sdk",
    "private": true,
    "version": "1.0.0",
    "type": "module",
    "exports": {
        ".": "./src/index.ts"
    },
    "scripts": {
        "build": "tsc -p tsconfig.build.json",
        "type-check": "tsc --noEmit"
    }
}

tsconfig.json:

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "noEmit": true
    },
    "include": [
        "src"
    ]
}

tsconfig.build.json:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false,
        "outDir": "dist",
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true
    },
    "include": [
        "src"
    ]
}

src/index.ts:

export interface HealthResponse {
  ok: boolean;
  service: string;
  app?: string;
}

export const createApiClient = (baseUrl: string) => {
  const getHealth = async (): Promise<HealthResponse> => {
    const res = await fetch(`${baseUrl}/health`);
    if (!res.ok) {
      throw new Error(`Health request failed: ${res.status}`);
    }
    return res.json() as Promise<HealthResponse>;
  };

  return { getHealth };
};

在根目录shell执行:

pnpm add @aitodos/shared@workspace:* --filter @aitodos/web
pnpm add @aitodos/shared@workspace:* --filter @aitodos/admin

Step 13:初始化 packages/store

  • 创建 store 包文件:package.jsontsconfig.jsontsconfig.build.jsonsrc/index.ts
  • 提供最小状态结构:AppStatecreateInitialState()
  • webadmin 中添加依赖:@aitodos/store: workspace:*
  • 结果:状态层共享包占位完成,可在 Day 2 继续扩展。

package.json:

{
    "name": "@aitodos/api-sdk",
    "private": true,
    "version": "1.0.0",
    "type": "module",
    "exports": {
        ".": "./src/index.ts"
    },
    "scripts": {
        "build": "tsc -p tsconfig.build.json",
        "type-check": "tsc --noEmit"
    }
}

tsconfig.json:

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "noEmit": true
    },
    "include": [
        "src"
    ]
}

tsconfig.build.json:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false,
        "outDir": "dist",
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true
    },
    "include": [
        "src"
    ]
}

src/index.ts:

export interface AppState {
  keyword: string;
}

export const createInitialState = (): AppState => ({
  keyword: ""
});

在根目录shell执行:

pnpm add @aitodos/store@workspace:* --filter @aitodos/web
pnpm add @aitodos/store@workspace:* --filter @aitodos/admin

Step 14:统一 workspace 包配置

  • 统一各工作区 name 命名为 scoped 包名(@aitodos/*)。
  • 应用包与基础包的 scripts 已按职责拆分(dev/build/type-check)。
  • 使用 workspace:* 明确内部依赖关系,避免误拉远程同名包。
  • 结果:各包职责与依赖边界清晰。

Step 15:统一 TypeScript 检查

  • 对关键应用执行 type-check@aitodos/web@aitodos/admin(均通过)。
  • 遇到 TS 6 对 baseUrl 的弃用报错后完成修正并复测通过。
  • 结果:Day 1 核心工作区具备可持续类型校验能力。

Step 16:补齐格式化配置

  • 新建根 .prettierrc,统一基础格式规则(分号、引号、尾逗号)。
  • 新建根 .prettierignore,忽略依赖与构建目录(node_modulesdistbuild.turbopnpm-lock.yaml)。
  • 结果:格式化策略可在全仓库复用。

Step 17:确认根任务调度可用

  • 根脚本 dev/build/lint/type-check/format 已配置并可由 turbo 调度。
  • pnpm dev 可识别工作区任务并并发拉起应用。
  • 结果:Monorepo 统一命令入口可用。

Step 18:安装与运行验收

  • 执行 pnpm install,确认 workspace 依赖解析正常。
  • 执行应用启动与检查命令:web/admin/server 均可启动,type-check 通过。
  • 运行中出现 esbuild build scripts 提示,为 pnpm 安全提示,不阻塞当前 Day 1。
  • 结果:完成 Day 1 运行态验收。

Step 19:首次提交与里程碑确认

  • 已完成首次提交:chore: initialize monorepo day1 foundation
  • 当前分支:masterHEAD 指向 Day 1 初始化提交。
  • 结果:形成可回溯的 Day 1 基线,便于 Day 2 继续迭代。

配置说明速记

  • packageManager: "pnpm@10.31.0"

    • 指定项目建议使用的包管理器与版本,确保团队和 CI 行为一致。
  • 根脚本含义

    • dev: turbo run dev,并发拉起各 workspace 的开发服务。
    • build: turbo run build,按依赖拓扑执行构建。
    • lint: turbo run lint,执行规范检查。
    • type-check: turbo run type-check,执行类型检查(通常不产物)。
    • format: prettier . --write,格式化并写回文件。
  • pnpm add vs pnpm i

    • add 用于新增依赖并写入 package.json
    • i/install 用于按现有清单安装依赖。
  • pnpm add -Dw ...

    • -D 表示安装到 devDependencies
    • -w 表示显式安装到 workspace root(避免 ERR_PNPM_ADDING_TO_ROOT)。
  • turbo.json 关键项

    • persistent: true:常驻任务(如 dev server)。
    • cache: false:开发任务不走缓存。
    • dependsOn: ["^build"]:先执行上游依赖包同名任务。
    • outputs:声明构建产物路径,供 Turbo 缓存复用。
  • tsconfig 关键项

    • include: ["src"]:限定 TS 检查/编译范围在源码目录。
    • noEmit: true:仅类型检查,不输出编译文件。
    • noEmit: false:允许输出构建产物(常用于 tsconfig.build.json)。
    • declaration: true:生成 .d.ts 类型声明。
    • declarationMap: true:生成类型声明映射,便于 IDE 跳转源码。
    • sourceMap: true:生成源码映射,便于调试定位 TS 源码行号。
  • workspace:*(如 "@aitodos/shared": "workspace:*"

    • 表示依赖当前 monorepo 内的本地包,而不是远程 registry。
    • 可确保跨包联动开发时始终引用本地最新代码。
  • scoped 包名里的 @

    • @aitodos/web@aitodos 是命名空间(scope),用于组织包并避免重名。
  • @types/* 是什么

    • @types/react,是社区提供的 TypeScript 类型声明包,通常放在 devDependencies
  • apps/web/node_modules 为什么会出现

    • 在 pnpm workspace 下属于正常现象,通常是链接结构,不代表依赖安装错误。
  • vite 启动时偶发 Exit code: 1

    • 常见于 IDE/终端中断常驻进程;若页面可访问且服务日志正常,一般不属于配置错误。