Files
DreamChat/doc/monorepo-guide.md

14 KiB

DreamChat Monorepo Guide

Overview

DreamChat uses a pnpm workspace monorepo structure to share code between frontend and backend, particularly for WebSocket types and API DTOs.

Monorepo Structure

dreamchat/
├── apps/
│   ├── backend/          # NestJS application
│   └── frontend/         # React + Vite application
├── packages/
│   ├── shared/           # Shared types & interfaces
│   └── config/           # Shared configurations (ESLint, TS)
├── prisma/               # Database schema (shared)
├── pnpm-workspace.yaml
└── package.json          # Root package.json

Root Configuration

package.json

{
  "name": "dreamchat",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@8.15.0",
  "scripts": {
    "build": "pnpm -r build",
    "dev": "pnpm -r --parallel dev",
    "test": "pnpm -r test",
    "lint": "pnpm -r lint",
    "db:migrate": "pnpm --filter backend db:migrate",
    "db:generate": "prisma generate",
    "db:studio": "prisma studio",
    "clean": "pnpm -r clean && rm -rf node_modules"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.3.0"
  }
}

pnpm-workspace.yaml

packages:
  - 'apps/*'
  - 'packages/*'

.npmrc

shamefully-hoist=true
auto-install-peers=true
strict-peer-dependencies=false

Package Structure

1. Shared Package (packages/shared)

Contains all shared types, interfaces, and DTOs used by both frontend and backend.

packages/shared/package.json

{
  "name": "@dreamchat/shared",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./websocket": {
      "import": "./dist/websocket/index.js",
      "types": "./dist/websocket/index.d.ts"
    },
    "./api": {
      "import": "./dist/api/index.js",
      "types": "./dist/api/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "clean": "rm -rf dist"
  },
  "devDependencies": {
    "typescript": "^5.3.0"
  }
}

packages/shared/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

packages/shared/src/websocket/events.ts

/**
 * WebSocket Event Types
 * Shared between frontend and backend for type-safe communication
 */

export enum WebSocketEventType {
  // Client -> Server
  JOIN_CONVERSATION = 'JOIN_CONVERSATION',
  LEAVE_CONVERSATION = 'LEAVE_CONVERSATION',
  SEND_MESSAGE = 'SEND_MESSAGE',
  STOP_GENERATION = 'STOP_GENERATION',

  // Server -> Client
  CONVERSATION_JOINED = 'CONVERSATION_JOINED',
  MESSAGE_ACK = 'MESSAGE_ACK',
  STREAM_CHUNK = 'STREAM_CHUNK',
  STREAM_COMPLETE = 'STREAM_COMPLETE',
  ERROR = 'ERROR',
}

// Base message interface
export interface WebSocketMessage<T = unknown> {
  type: WebSocketEventType;
  payload: T;
  timestamp: string;
  requestId?: string;
}

// Client -> Server payloads
export interface JoinConversationPayload {
  conversationId: string;
}

export interface LeaveConversationPayload {
  conversationId: string;
}

export interface SendMessagePayload {
  conversationId: string;
  content: string;
  streaming?: boolean;
}

export interface StopGenerationPayload {
  conversationId: string;
}

// Server -> Client payloads
export interface ConversationJoinedPayload {
  conversationId: string;
  history: MessageHistoryItem[];
}

export interface MessageHistoryItem {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  createdAt: string;
}

export interface MessageAckPayload {
  messageId: string;
  status: 'received' | 'processing' | 'error';
}

export interface StreamChunkPayload {
  conversationId: string;
  chunk: string;
  isComplete: boolean;
}

export interface StreamCompletePayload {
  conversationId: string;
  messageId: string;
  content: string;
  tokensUsed?: number;
  model?: string;
}

export interface ErrorPayload {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}

packages/shared/src/websocket/index.ts

export * from './events.js';

packages/shared/src/api/dto.ts

/**
 * Shared API DTOs
 * Used by backend for validation and frontend for type safety
 */

// Character DTOs
export interface CreateCharacterDto {
  name: string;
  personalityPrompt: string;
  backstory?: string;
  attributes?: Record<string, unknown>;
  avatarUrl?: string;
  config?: Record<string, unknown>;
}

export interface UpdateCharacterDto extends Partial<CreateCharacterDto> {}

export interface CharacterResponseDto {
  id: string;
  name: string;
  avatarUrl?: string;
  personalityPrompt: string;
  backstory?: string;
  attributes: Record<string, unknown>;
  config: Record<string, unknown>;
  isPublic: boolean;
  createdAt: string;
  updatedAt: string;
}

// Conversation DTOs
export interface CreateConversationDto {
  characterId: string;
  title?: string;
}

export interface ConversationResponseDto {
  id: string;
  title?: string;
  characterId: string;
  messageCount: number;
  totalTokens: number;
  createdAt: string;
  updatedAt: string;
}

// Message DTOs
export interface CreateMessageDto {
  content: string;
}

export interface MessageResponseDto {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  tokensUsed?: number;
  model?: string;
  metadata?: Record<string, unknown>;
  createdAt: string;
}

// Auth DTOs
export interface LoginDto {
  username: string;
  password: string;
}

export interface RegisterDto {
  email: string;
  username: string;
  password: string;
}

export interface AuthResponseDto {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
  user: UserResponseDto;
}

export interface UserResponseDto {
  id: string;
  email: string;
  username: string;
  role: 'USER' | 'ADMIN';
}

packages/shared/src/api/index.ts

export * from './dto.js';

packages/shared/src/index.ts

export * from './websocket/index.js';
export * from './api/index.js';

2. Backend (apps/backend)

apps/backend/package.json

{
  "name": "@dreamchat/backend",
  "version": "1.0.0",
  "scripts": {
    "build": "nest build",
    "dev": "nest start --watch",
    "start": "node dist/main",
    "test": "jest",
    "test:watch": "jest --watch",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
    "db:migrate": "prisma migrate deploy",
    "db:generate": "prisma generate",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@dreamchat/shared": "workspace:*",
    "@nestjs/common": "^10.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-socket.io": "^10.0.0",
    "@nestjs/websockets": "^10.0.0",
    "@prisma/client": "^5.10.0",
    "@xenova/transformers": "^2.15.0",
    "socket.io": "^4.7.0",
    "reflect-metadata": "^0.2.0",
    "rxjs": "^7.8.0"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@nestjs/testing": "^10.0.0",
    "@types/node": "^20.0.0",
    "jest": "^29.0.0",
    "prisma": "^5.10.0",
    "typescript": "^5.3.0"
  }
}

Using Shared Package in Backend

// apps/backend/src/chat/chat.gateway.ts
import {
  WebSocketEventType,
  WebSocketMessage,
  JoinConversationPayload,
  SendMessagePayload,
  StreamChunkPayload,
  ConversationJoinedPayload,
} from '@dreamchat/shared';

@WebSocketGateway({ namespace: 'chat' })
export class ChatGateway implements OnGatewayConnection {

  @SubscribeMessage(WebSocketEventType.JOIN_CONVERSATION)
  async handleJoin(
    @MessageBody() payload: JoinConversationPayload,
    @ConnectedSocket() client: Socket,
  ) {
    // Implementation
    const response: WebSocketMessage<ConversationJoinedPayload> = {
      type: WebSocketEventType.CONVERSATION_JOINED,
      payload: { conversationId: payload.conversationId, history: [] },
      timestamp: new Date().toISOString(),
    };
    client.emit(response.type, response);
  }

  @SubscribeMessage(WebSocketEventType.SEND_MESSAGE)
  async handleMessage(
    @MessageBody() payload: SendMessagePayload,
    @ConnectedSocket() client: Socket,
  ) {
    // Stream chunks with type safety
    const chunk: WebSocketMessage<StreamChunkPayload> = {
      type: WebSocketEventType.STREAM_CHUNK,
      payload: {
        conversationId: payload.conversationId,
        chunk: ' partial text',
        isComplete: false,
      },
      timestamp: new Date().toISOString(),
    };
    client.emit(chunk.type, chunk);
  }
}

3. Frontend (apps/frontend)

apps/frontend/package.json

{
  "name": "@dreamchat/frontend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "lint": "eslint . --ext ts,tsx",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@dreamchat/shared": "workspace:*",
    "@tanstack/react-query": "^5.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.20.0",
    "socket.io-client": "^4.7.0",
    "zustand": "^4.5.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.2.0",
    "typescript": "^5.3.0",
    "vite": "^5.0.0",
    "vitest": "^1.0.0"
  }
}

Using Shared Package in Frontend

// apps/frontend/src/hooks/use-chat.ts
import { useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import {
  WebSocketEventType,
  WebSocketMessage,
  JoinConversationPayload,
  SendMessagePayload,
  StreamChunkPayload,
  StreamCompletePayload,
} from '@dreamchat/shared';

export function useChat(conversationId: string) {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const s = io('ws://localhost:3000/chat', {
      auth: { token: getAuthToken() },
    });

    s.on(WebSocketEventType.STREAM_CHUNK, (msg: WebSocketMessage<StreamChunkPayload>) => {
      setMessages((prev) => [...prev, msg.payload.chunk]);
    });

    s.on(WebSocketEventType.STREAM_COMPLETE, (msg: WebSocketMessage<StreamCompletePayload>) => {
      // Handle completion
    });

    setSocket(s);

    // Join conversation
    const joinPayload: JoinConversationPayload = { conversationId };
    s.emit(WebSocketEventType.JOIN_CONVERSATION, joinPayload);

    return () => {
      s.disconnect();
    };
  }, [conversationId]);

  const sendMessage = useCallback((content: string) => {
    if (!socket) return;

    const payload: SendMessagePayload = {
      conversationId,
      content,
      streaming: true,
    };
    socket.emit(WebSocketEventType.SEND_MESSAGE, payload);
  }, [socket, conversationId]);

  return { messages, sendMessage };
}

Development Workflow

Initial Setup

# 1. Install pnpm (if not already installed)
npm install -g pnpm

# 2. Clone and setup
git clone <repo-url>
cd dreamchat

# 3. Install all dependencies
pnpm install

# 4. Build shared packages first
pnpm --filter @dreamchat/shared build

# 5. Generate Prisma client
pnpm db:generate

# 6. Start development (runs all apps in parallel)
pnpm dev

Adding Dependencies

# Add dependency to root
pnpm add -D typescript

# Add dependency to specific app
pnpm --filter @dreamchat/backend add @nestjs/jwt

# Add dependency to shared package
pnpm --filter @dreamchat/shared add zod

# Add shared package to backend/frontend
pnpm --filter @dreamchat/backend add @dreamchat/shared@workspace:*

Building

# Build all packages
pnpm build

# Build specific package
pnpm --filter @dreamchat/shared build

# Watch mode for shared package (during development)
pnpm --filter @dreamchat/shared dev

Testing

# Test all
pnpm test

# Test specific package
pnpm --filter @dreamchat/backend test

# Test with coverage
pnpm --filter @dreamchat/backend test --coverage

Docker Integration

Development Docker Compose

# docker-compose.dev.yml
version: '3.8'

services:
  backend:
    build:
      context: .
      dockerfile: apps/backend/Dockerfile
      target: development
    volumes:
      - .:/workspace
      - /workspace/node_modules
      - /workspace/apps/backend/node_modules
      - /workspace/packages/shared/node_modules
    environment:
      - NODE_ENV=development
    command: pnpm --filter @dreamchat/backend dev

  frontend:
    build:
      context: .
      dockerfile: apps/frontend/Dockerfile
      target: development
    volumes:
      - .:/workspace
      - /workspace/node_modules
      - /workspace/apps/frontend/node_modules
      - /workspace/packages/shared/node_modules
    command: pnpm --filter @dreamchat/frontend dev

Production Dockerfile (Backend)

# apps/backend/Dockerfile
FROM node:24-alpine AS base
RUN npm install -g pnpm

FROM base AS dependencies
WORKDIR /app
COPY pnpm-workspace.yaml package.json ./
COPY apps/backend/package.json ./apps/backend/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile

FROM base AS build
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/apps/backend/node_modules ./apps/backend/node_modules
COPY --from=dependencies /app/packages/shared/node_modules ./packages/shared/node_modules
COPY . .
RUN pnpm --filter @dreamchat/shared build
RUN pnpm --filter @dreamchat/backend build

FROM base AS production
WORKDIR /app
COPY --from=build /app/apps/backend/dist ./dist
COPY --from=build /app/apps/backend/node_modules ./node_modules
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/main.js"]

Benefits of This Structure

  1. Type Safety: Shared WebSocket events and DTOs ensure frontend and backend stay in sync
  2. Single Source of Truth: Changes to types in packages/shared propagate to both apps
  3. Efficient Development: pnpm workspaces deduplicate dependencies, saving disk space
  4. Simplified CI/CD: Single repository with unified versioning
  5. Code Reuse: Utilities and constants can be shared

Migration from Non-Monorepo

If migrating an existing project:

  1. Create new monorepo structure
  2. Move backend to apps/backend
  3. Move frontend to apps/frontend
  4. Extract shared types to packages/shared
  5. Update all imports to use @dreamchat/shared
  6. Update Docker files to use pnpm