14 KiB
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
- Type Safety: Shared WebSocket events and DTOs ensure frontend and backend stay in sync
- Single Source of Truth: Changes to types in
packages/sharedpropagate to both apps - Efficient Development: pnpm workspaces deduplicate dependencies, saving disk space
- Simplified CI/CD: Single repository with unified versioning
- Code Reuse: Utilities and constants can be shared
Migration from Non-Monorepo
If migrating an existing project:
- Create new monorepo structure
- Move backend to
apps/backend - Move frontend to
apps/frontend - Extract shared types to
packages/shared - Update all imports to use
@dreamchat/shared - Update Docker files to use pnpm