openwa/
├── src/
│ ├── main.ts # Application entry
│ ├── app.module.ts # Root module
│ ├── common/ # Shared cache, security, storage, errors, utils
│ ├── config/ # Runtime config, env validation, bootstrap security, Swagger
│ ├── core/ # Hook and plugin framework
│ ├── database/ # TypeORM data sources and migrations
│ ├── engine/ # WhatsApp engine abstraction, adapters, identity mapping,
│ │ # and the built-in engine plugins (engine/builtin/)
│ ├── modules/ # API feature modules
├── test/ # E2E smoke tests and mocks
├── dashboard/ # React/Vite dashboard
├── sdk/ # Client SDKs: go, java, javascript, php, python
├── docs/ # Documentation
├── scripts/ # Utility scripts
├── .github/workflows/ # CI and release workflows
├── package.json
├── tsconfig.json
├── nest-cli.json
├── eslint.config.mjs
├── docker-compose.yml
├── docker-compose.dev.yml
├── Dockerfile
└── README.md
// tsconfig.json (abridged — see the file for the commented rationale)
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"target": "ES2023",
"rootDir": ".",
"outDir": "./dist",
"types": ["node", "jest"],
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"],
"exclude": ["node_modules", "dist", "dashboard"]
}There are no path aliases — no baseUrl, no paths. Import with relative paths
(../common/services/logger.service), not @/…. The whole strict family is on, with no opt-outs,
so entity and DTO properties the framework populates carry a definite-assignment !
(name!: string;), since no constructor assigns them. types must be
listed explicitly because TypeScript 6 no longer auto-includes every @types package.
The backend uses ESLint flat config in eslint.config.mjs with type-aware TypeScript rules,
Prettier integration, and an architecture guard for controllers. HTTP controllers must call
capability services; they must not import IWhatsAppEngine or EngineRegistry, call getEngine(),
or resolve an engine via engines.require() / engines.get().
npm run lint
npm run lint:fixThe dashboard has its own package scripts:
cd dashboard
npm run lint// Files: kebab-case
session.controller.ts
send-message.dto.ts
api-key.guard.ts
// Classes: PascalCase
class SessionController {}
class SendMessageDto {}
class ApiKeyGuard {}
// Interfaces: PascalCase with 'I' prefix (optional)
interface ISessionConfig {}
interface SessionConfig {} // Also acceptable
// Functions/Methods: camelCase
function createSession() {}
async sendMessage() {}
// Variables: camelCase
const sessionId = 'abc';
let messageCount = 0;
// Constants: UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3;
const DEFAULT_TIMEOUT = 30000;
// Enums: PascalCase name with UPPER_SNAKE_CASE values
enum SessionStatus {
CREATED = 'created',
READY = 'ready',
DISCONNECTED = 'disconnected',
}// modules/example/example.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExampleController } from './example.controller';
import { ExampleService } from './example.service';
import { ExampleRepository } from './example.repository';
import { Example } from './entities/example.entity';
@Module({
imports: [TypeOrmModule.forFeature([Example], 'data')],
controllers: [ExampleController],
providers: [ExampleService, ExampleRepository],
exports: [ExampleService],
})
export class ExampleModule {}// modules/example/example.controller.ts
import { Controller, Get, Post, Body, Param, Delete, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ExampleService } from './example.service';
import { CreateExampleDto } from './dto/create-example.dto';
import { ExampleResponseDto } from './dto/example-response.dto';
@ApiTags('examples')
@Controller('examples')
export class ExampleController {
constructor(private readonly exampleService: ExampleService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create example' })
@ApiResponse({ status: 201, type: ExampleResponseDto })
async create(@Body() dto: CreateExampleDto): Promise<ExampleResponseDto> {
return this.exampleService.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get example by ID' })
@ApiResponse({ status: 200, type: ExampleResponseDto })
async findOne(@Param('id') id: string): Promise<ExampleResponseDto> {
return this.exampleService.findOne(id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete example' })
async remove(@Param('id') id: string): Promise<void> {
return this.exampleService.remove(id);
}
}Controllers are protected by the global API key guard unless marked with @Public(). Keep
controllers thin: validate transport input through DTOs, delegate behavior to services, and never
resolve an engine directly from a controller. Engine-specific details belong behind capability
services and engine adapters.
A capability service reaches the live engine through EngineRegistry, the narrow port exported by
the (global) EngineModule — not through SessionService, which drives the session lifecycle
(start/stop/delete/reconnect, owned by SessionEngineLifecycle) and should only be injected by code
that actually drives it:
@Injectable()
export class ExampleService {
constructor(private readonly engines: EngineRegistry) {}
// require() throws 400 "Session is not started" by default; pass a factory to keep an
// endpoint's own documented status/message.
private getEngine(sessionId: string): IWhatsAppEngine {
return this.engines.require(sessionId);
}
}// modules/example/example.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { createLogger } from '../../common/services/logger.service';
import { ExampleRepository } from './example.repository';
import { CreateExampleDto } from './dto/create-example.dto';
import { Example } from './entities/example.entity';
@Injectable()
export class ExampleService {
private readonly logger = createLogger('ExampleService');
constructor(private readonly repository: ExampleRepository) {}
async create(dto: CreateExampleDto): Promise<Example> {
this.logger.log(`Creating example: ${dto.name}`);
const example = this.repository.create(dto);
return this.repository.save(example);
}
async findOne(id: string): Promise<Example> {
const example = await this.repository.findOne({ where: { id } });
if (!example) {
throw new NotFoundException(`Example with ID ${id} not found`);
}
return example;
}
async remove(id: string): Promise<void> {
const example = await this.findOne(id);
await this.repository.remove(example);
this.logger.log(`Deleted example: ${id}`);
}
}// modules/example/dto/create-example.dto.ts
import { IsString, IsOptional, MaxLength, IsUrl } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateExampleDto {
@ApiProperty({ description: 'Example name', example: 'My Example' })
@IsString()
@MaxLength(100)
name!: string;
@ApiPropertyOptional({ description: 'Optional description' })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ description: 'Callback URL' })
@IsOptional()
@IsUrl({ protocols: ['https'] })
callbackUrl?: string;
}@Global() makes a module's exported providers injectable everywhere without an import, which is
convenient exactly until two modules both assume they own a name and the injector silently picks
one. The sanctioned set is therefore frozen at these ten — every one of them a cross-cutting
concern that nearly every module would otherwise have to import:
| Module | Path | Why it is global |
|---|---|---|
AuthModule |
src/modules/auth/auth.module.ts |
API-key guard + role checks run on nearly every route |
AuditModule |
src/modules/audit/audit.module.ts |
Every mutation surface writes audit entries |
EventsModule |
src/modules/events/events.module.ts |
The event bus fans out from every module |
EngineModule |
src/engine/engine.module.ts |
EngineRegistry, the narrow port to the live engines |
PluginsModule |
src/core/plugins/plugins.module.ts |
Plugin services are consumed across modules |
HooksModule |
src/core/hooks/hooks.module.ts |
HookManager is invoked from unrelated modules |
CacheModule |
src/common/cache/cache.module.ts |
Shared cache service |
StorageModule |
src/common/storage/storage.module.ts |
File/media storage used across modules |
LoggerModule |
src/common/services/logger.module.ts |
The logger is needed literally everywhere |
AgentToolsModule |
src/core/agent-tools/agent-tools.module.ts |
The tool registry is shared by MCP and the REST layer |
Do not add an eleventh. A new global needs an ADR-level justification — written down and reviewed
with the same weight as an architecture decision record — because the cost (implicit coupling,
order-dependent provider resolution, tests that pass only because the whole app booted) is paid by
every future module, not by the one that opts out of an explicit imports entry. The default for a
new module is the standard template above: declare exports and let consumers imports you.
main is the only long-lived branch. Each change is made on a short-lived branch cut from main,
opened as a pull request against main, and merged with a merge commit once CI passes. Releases are
tagged from main; see 15.7 Cutting a Release.
fix/* # Bug fixes
feat/* # New features
docs/* # Documentation only
chore/* # Tooling, dependencies, release housekeeping
refactor/* # Restructuring without a behavior change
test/* # Tests only
Examples:
fix/qr-code-timeout
feat/webhook-retry
docs/update-api-reference
<type>(<scope>): <subject>
<body>
<footer>
Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation
- style: Formatting (no code change)
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance
Examples:
feat(session): add multi-session support
- Implement session manager for multiple sessions
- Add session limit configuration
- Update documentation
Closes #123
fix(webhook): handle timeout errors gracefully
Previously, webhook timeouts would crash the worker.
Now they are caught and logged properly.
Fixes #456
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] Tests added/updated
- [ ] Documentation updated
- [ ] Lint passes
- [ ] Self-reviewed
## Screenshots (if applicable)
## Related Issues
Closes #Unit tests live next to source files as *.spec.ts. E2E smoke tests live in test/.
src/
├── common/security/ssrf-guard.spec.ts
├── engine/adapters/baileys.adapter.spec.ts
├── modules/session/session.service.spec.ts
└── modules/webhook/webhook.service.spec.ts
test/
├── app.e2e-spec.ts
├── baileys-engine.e2e-spec.ts
├── serve-static.e2e-spec.ts
├── jest-e2e.json
└── setup-e2e.ts
// src/modules/session/reconnect-config.spec.ts
import { resolveReconnectConfig } from './session-engine-lifecycle.service';
describe('resolveReconnectConfig', () => {
it('keeps reconnect settings finite and bounded', () => {
// Invalid maxReconnectAttempts falls back to the default: unlimited retries (the backoff
// parks at the 5-minute cap); an invalid baseDelay is clamped up to the 1s minimum.
expect(resolveReconnectConfig({ maxReconnectAttempts: 'bad', reconnectBaseDelay: -1 })).toEqual({
maxAttempts: Number.POSITIVE_INFINITY,
baseDelay: 1000,
});
});
});// test/app.e2e-spec.ts
// archiver is ESM-only and AppModule pulls it in; stub it so ts-jest (CommonJS) can load the graph.
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { applyGlobalValidation } from '../src/config/app-validation';
describe('App (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
applyGlobalValidation(app);
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('GET /api/health', () => {
it('returns health status without an API key', () => {
return request(app.getHttpServer())
.get('/api/health')
.expect(200)
.expect(res => {
expect(res.body.status).toBe('ok');
});
});
});
});Run the normal backend checks before opening a PR:
npm test -- --runInBand
npm run test:e2e -- --runInBand
npm run lintCoverage thresholds are enforced by Jest in package.json. Security-sensitive code under
src/common/security/ has stricter thresholds than the global baseline.
/**
* Session service handles all session-related operations.
*
* @example
* ```typescript
* const session = await sessionService.create({ name: 'my-bot' });
* console.log(session.id);
* ```
*/
@Injectable()
export class SessionService {
/**
* Creates a new WhatsApp session.
*
* @param dto - Session creation parameters
* @returns The created session with QR code if applicable
* @throws {ConflictException} If session name already exists
* @throws {ServiceUnavailableException} If max sessions reached
*/
async create(dto: CreateSessionDto): Promise<Session> {
// Implementation
}
}@ApiTags('sessions')
@Controller('sessions')
export class SessionController {
@Post()
@ApiOperation({
summary: 'Create a new session',
description: 'Creates a new WhatsApp session and returns QR code for authentication',
})
@ApiBody({ type: CreateSessionDto })
@ApiResponse({
status: 201,
description: 'Session created successfully',
type: SessionResponseDto,
})
@ApiResponse({
status: 409,
description: 'Session name already exists',
})
async create(@Body() dto: CreateSessionDto): Promise<SessionResponseDto> {
// Implementation
}
}There is no custom exception base class and no global exception filter. Handlers throw NestJS's
built-in HTTP exceptions and NestJS's own BaseExceptionFilter renders them, so responses carry the
framework default shape { statusCode, message, error }.
throw new BadRequestException('Session is not started');
throw new NotFoundException(`Contact ${contactId} not found`);Where a failure mode recurs across engines, src/common/errors/ defines a named error that extends
the NestJS exception carrying the right status, so throwing it from an adapter maps to the intended
HTTP code with no filter involved:
| Error | Extends | Status |
|---|---|---|
EngineNotSupportedError |
NotImplementedException |
501 |
ChannelMediaNotSupportedError |
NotImplementedException |
501 |
EngineNotReadyError |
ConflictException |
409 |
EngineRefusedError |
ForbiddenException |
403 |
EngineTransportError |
ServiceUnavailableException |
503 |
ChatLabelsUnsupportedError |
UnprocessableEntityException |
422 |
CallNotFoundError / ChannelNotFoundError / GroupNotFoundError / MessageNotFoundError |
NotFoundException |
404 |
Add a new one only when the condition is engine-agnostic and recurs; a one-off stays an inline
throw new BadRequestException(...).
Do not introduce a response envelope. A
ResponseInterceptor/HttpExceptionFilterpair that wrapped every payload in{ success, data, meta }was written once, never registered, and has since been deleted —dashboard/src/services/api.tsreads the raw payload.
# Required
- Node.js 22.19 or newer (22 LTS)
- npm 10+
- Docker & Docker Compose
- Git
# Optional (for development)
- VS Code with extensions
- Postman or Insomnia
- pgAdmin or DBeaverDependency installation patches whatsapp-web.js for the WhatsApp Web 2.3000.x message-id rename. It applies the patch with GNU
patch, falling back togit apply— so Git alone is enough, including on Windows outside Git Bash. With neither available the install still succeeds, but the whatsapp-web.js engine then fails on every send; seedocs/12-troubleshooting-faq.md.The committed lockfile resolves dependencies from the npm registry and works with npm 12's default Git-dependency block. Do not work around
EALLOWGITby changing npm or Git configuration globally; update to a current lockfile instead.
# 1. Clone repository
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA
# 2. Install the locked dependencies (also installs dashboard dependencies)
npm ci
# 3. Start API + dashboard in development mode
npm run devUse npm install only when intentionally changing dependencies and updating the lockfile.
On first boot the API creates data/.env.generated with a minimal SQLite/local-storage
configuration. A project-level .env is optional; real process environment variables take precedence
over .env, which takes precedence over data/.env.generated.
For a production-image local smoke test:
docker compose -f docker-compose.dev.yml up -d --buildFor production compose:
docker compose up -d
docker compose --profile postgres up -d
docker compose --profile full up -dA profile only starts the extra containers. Point OpenWA at them first, from Dashboard >
Infrastructure or with the .env variables listed under Production Deployment in the README;
otherwise it stays on SQLite and local storage. The dashboard's built-in storage option creates its
own openwa-minio container, so do not also start the minio or full profile for it. The same goes
for built-in PostgreSQL and Redis: each built-in option creates its own container, and the compose
profiles are for the manual .env route. The compose minio service starts only once
S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are set in the .env next to docker-compose.yml; if it
was already started without them, run docker rm -f openwa-minio before you enable built-in storage.
// .vscode/extensions.json
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker",
"humao.rest-client",
"bradlc.vscode-tailwindcss",
"orta.vscode-jest"
]
}// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"typescript.preferences.importModuleSpecifier": "relative",
"files.exclude": {
"**/node_modules": true,
"**/dist": true
}
}OpenWA supports multiple infrastructure configurations. Choose based on your needs:
# Keys the dashboard manages (Dashboard > Infrastructure) are commented out with their defaults:
# an uncommented key in .env pins that value over the one the dashboard saves (see the header of
# `.env.example`). Uncomment only what you intend to manage by hand.
# Application
NODE_ENV=development
PORT=2785
LOG_LEVEL=debug
# Database: SQLite (zero config)
# DATABASE_TYPE=sqlite
# DATABASE_NAME=./data/openwa.sqlite
# SQLite runs migrations by default. DATABASE_SYNCHRONIZE=true is SQLite-only: PostgreSQL refuses it
# at boot, so an uncommented true here stops a later switch to PostgreSQL in the dashboard from booting.
# DATABASE_SYNCHRONIZE=false
# Storage: Local filesystem
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=./data/media
# Redis and queue disabled by default
# REDIS_ENABLED=false
# QUEUE_ENABLED=false
# Optional: seed a known admin key. If omitted, OpenWA generates a random key and writes data/.api-key.
API_MASTER_KEY=
# Session
# SESSION_DATA_PATH=./data/sessions
# Engine: whatsapp-web.js = Chromium-based; baileys = browser-free WebSocket
# ENGINE_TYPE=whatsapp-web.js
# PUPPETEER_HEADLESS=true
# Swagger defaults ON outside production and OFF under NODE_ENV=production.
# Set it explicitly to force either way.
ENABLE_SWAGGER=true# Keys the dashboard manages (Dashboard > Infrastructure) are commented out with the values this
# profile uses; set the ones that differ from the defaults (PostgreSQL, Redis, the queue, /app/data
# paths) there. An uncommented key in .env pins that value over the one the dashboard saves (see the
# header of `.env.example`). Uncomment only what you intend to manage by hand.
# Application
NODE_ENV=production
PORT=2785
LOG_LEVEL=info
# Database: PostgreSQL
# DATABASE_TYPE=postgres
# DATABASE_HOST=postgres
# DATABASE_PORT=5432
# DATABASE_USERNAME=openwa
# DATABASE_PASSWORD=<set-a-strong-password>
# DATABASE_NAME=openwa
DATABASE_SYNCHRONIZE=false
# DATABASE_POOL_SIZE=10
# Storage: Local filesystem
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=/app/data/media
# Cache: Redis
# REDIS_ENABLED=true
# REDIS_HOST=redis
# REDIS_PORT=6379
# QUEUE_ENABLED=true
# Security
# First-boot seed for the initial ADMIN key, ignored once any key exists. Leave it unset to
# generate a random key into data/.api-key, or set one generated with: openssl rand -base64 32
# API_MASTER_KEY=
# Optional HMAC pepper so a DB leak alone can't precompute key hashes. Generate a random value
# (openssl rand -base64 32); setting or changing it invalidates every API key issued before.
# API_KEY_PEPPER=
CORS_ORIGINS=https://dashboard.example.com
# Session
# SESSION_DATA_PATH=/app/data/sessions
# Engine
# ENGINE_TYPE=whatsapp-web.js
# PUPPETEER_HEADLESS=true
# PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
ENABLE_SWAGGER=falseTip
For development, use the minimal profile with SQLite. PostgreSQL, Redis, and S3/MinIO are optional.
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug NestJS",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start:debug"],
"console": "integratedTerminal",
"restart": true,
"autoAttachChildProcesses": true
},
{
"name": "Debug Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "test:debug"],
"console": "integratedTerminal"
}
]
}// Use createLogger from the shared LoggerService, not console.*
import { Injectable } from '@nestjs/common';
import { createLogger } from '../common/services/logger.service';
@Injectable()
export class MyService {
private readonly logger = createLogger('MyService');
async doSomething(id: string): Promise<void> {
this.logger.log('Processing item', { id });
try {
await this.process(id);
this.logger.log('Item processed successfully', { id });
} catch (error) {
// Log error with full stack
this.logger.error('Failed to process item', error instanceof Error ? error.stack : String(error), { id });
throw error;
}
}
}Note
LoggerService stamps the request ID on every log line on its own (it reads it through getRequestId()). Do
not pass X-Request-ID from controller to service, and do not make a provider request-scoped to reach it;
call getRequestId() from common/services/request-context when code needs the value.
requestContextMiddleware (src/common/middleware/request-context.middleware.ts, registered in
src/configure-app.ts) gives every request an ID. It keeps a client X-Request-ID only when it matches
^[A-Za-z0-9-]{1,128}$ and otherwise generates a UUID, echoes it on the X-Request-ID response header,
and runs the request inside AsyncLocalStorage (runWithRequestId), so getRequestId(), every log line
and every audit row carry it. There is no request-ID interceptor.
// Enable verbose logging for whatsapp-web.js
const client = new Client({
puppeteer: {
headless: false, // See browser window
devtools: true, // Open DevTools automatically
},
});
// Log all events for debugging
const events = ['qr', 'ready', 'authenticated', 'disconnected', 'message'];
events.forEach(event => {
client.on(event, (...args) => {
console.log(`[WA Event: ${event}]`, JSON.stringify(args, null, 2));
});
});# Run single test file
npm test -- session.service.spec.ts
# Run tests with verbose output
npm test -- --verbose
# Check for TypeScript errors without emitting (the Nest CLI has no --noEmit; call tsc directly).
# Use the root tsconfig, not tsconfig.build.json — it is the only one that also type-checks the
# colocated *.spec.ts files, which is what CI gates on.
npx tsc --noEmit -p tsconfig.json
# Lint with auto-fix
npm run lint -- --fix
# Debug database queries (TypeORM) — add to .env:
# DATABASE_LOGGING=true
# (there is no DEBUG=typeorm:query switch; both connections read DATABASE_LOGGING)
# PII warning: this logs full queries WITH bound parameters — message bodies and phone
# numbers end up in the application log. Use on a local/debug data set only, never in production.
# View Docker logs (service is `openwa-api` in docker-compose.yml, `openwa` in
# docker-compose.dev.yml — there is no service named `app`)
docker compose logs -f openwa-api// ❌ Bad: N+1 query problem
const sessions = await sessionRepo.find();
for (const session of sessions) {
session.webhooks = await webhookRepo.find({ where: { sessionId: session.id } });
}
// ✅ Good: Use relations
const sessions = await sessionRepo.find({
relations: ['webhooks'],
});
// ✅ Good: Use QueryBuilder for complex queries
const sessions = await sessionRepo
.createQueryBuilder('session')
.leftJoinAndSelect('session.webhooks', 'webhook')
.where('session.status = :status', { status: 'ready' })
.orderBy('session.createdAt', 'DESC')
.take(10)
.getMany();Memoize a hot, expensive read in-process with a TTL, as StatsService does for its aggregate
responses (STATS_CACHE_TTL_MS, default 30000; 0 disables the memo). CacheService is an optional,
fail-open Redis layer: when Redis is disabled or down every read returns null and every write is a
no-op, so never make a request path depend on it. No request path reads from it today (see
3.13.3 Cache Service).
// ❌ Bad: Sequential execution
const contact1 = await getContact('id1');
const contact2 = await getContact('id2');
const contact3 = await getContact('id3');
// ✅ Good: Parallel execution
const [contact1, contact2, contact3] = await Promise.all([getContact('id1'), getContact('id2'), getContact('id3')]);
// ✅ Good: Batch processing with concurrency limit
import pLimit from 'p-limit';
const limit = pLimit(5); // Max 5 concurrent
const results = await Promise.all(chatIds.map(id => limit(() => sendMessage(id, text))));// Bound teardown so one stuck browser/socket cannot block shutdown.
@Injectable()
export class EngineTeardownService {
private readonly logger = createLogger('EngineTeardownService');
async destroyWithTimeout(sessionId: string, engine: IWhatsAppEngine): Promise<void> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('engine.destroy() timed out')), 10_000);
});
try {
await Promise.race([engine.destroy(), timeout]);
} catch (error) {
this.logger.warn(`Engine teardown failed for ${sessionId}: ${String(error)}`);
}
}
}Symptom: Session stuck in 'initializing' status
Causes & Solutions:
-
Chrome/Puppeteer issue
- Ensure Chrome for Testing is installed:
ls /usr/local/bin/puppeteer-chrome - Check Puppeteer args (
PUPPETEER_ARGS); the default is--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu
- Ensure Chrome for Testing is installed:
-
Previous session data corrupted
- Clear the stored auth/session data for the session under
data/sessions - For Baileys, also check
BAILEYS_AUTH_DIR(default./data/baileys)
- Clear the stored auth/session data for the session under
-
WhatsApp rate limit
- Wait 5-10 minutes before retrying
Causes & Solutions:
-
Memory pressure
- Monitor memory:
docker stats - Increase container memory limit
- Monitor memory:
-
Network issues
- Check WebSocket connection stability
- Implement auto-reconnect logic
-
WhatsApp detected automation
- Add random delays between messages
- Avoid sending too many messages quickly
Symptom: "too many clients already" error
Solution:
Set DATABASE_POOL_SIZE in .env (default 10). It becomes the pool max of the PostgreSQL data
connection, both in the app and in the migration CLI. Keep the pool size times the number of
replicas below the server's max_connections. See
12 - Troubleshooting for the related
timeouts.
Symptom: "relation already exists" error
Solution:
# Check migration status
npm run migration:show
# Revert last migration
npm run migration:revert
# Regenerate migration
npm run migration:generate --name=FixMigrationSymptom: "Nest can't resolve dependencies" or an undefined injected provider at startup
Solution: this codebase does not use forwardRef. Break the cycle by moving the shared piece into
a lower module that both sides import (for example the global EngineModule's EngineRegistry), or
by one-directional delegation, as SessionService does to SessionEngineLifecycle (see the class
comment on SessionService in src/modules/session/session.service.ts).
Symptom: "Nest can't resolve dependencies"
Solution:
- Ensure provider is exported from its module
- Check if module is imported where needed
- Use @Injectable() decorator on services
Check logs:
docker compose logs openwa-api --tail 100Common causes:
- Missing environment variables
- Database not ready (use depends_on + healthcheck)
- Port already in use
Solution:
# Add shared memory size
docker run --shm-size=2gb openwaOr in docker-compose.yml:
services:
openwa-api:
shm_size: '2gb'1. Fork the repository
2. Create feature branch: `git checkout -b feat/amazing-feature`
3. Make changes following our coding standards
4. Write/update tests
5. Run linter: `npm run lint`
6. Run tests: `npm test`
7. Commit: `git commit -m 'feat(scope): add amazing feature'`
8. Push: `git push origin feat/amazing-feature`
9. Open Pull Request- [ ] Code follows project style guide
- [ ] Tests are included and passing
- [ ] Documentation is updated
- [ ] No console.log statements
- [ ] Error handling is proper
- [ ] No hardcoded values
- [ ] Security considerations addressed
- [ ] Performance impact considered**Bug Report Template:**
- **Description:** Clear description of the bug
- **Steps to Reproduce:** Numbered steps
- **Expected Behavior:** What should happen
- **Actual Behavior:** What actually happens
- **Environment:** Node version, OS, Docker version
- **Logs:** Relevant error logs