Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
Changelog

## [4.9.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.8.0...v4.9.0) (2026-05-25)

### presence

* **presence:** add @Public() to PresenceController and fix e2e race conditions ([6245e6d](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/6245e6d265d6a086d3aa6813df3c1cd71fa7303d))
* **presence:** add DynamicApiPresenceModule with pluggable adapters ([5957c54](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/5957c54b62664f5861123468f50068861aff7b78))
* **presence:** break circular dependency by moving presence out of modules barrel ([51fad6a](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/51fad6a7fe8b7dd3472bbd27fd25ff6f70a80267))
* **presence:** replace String(err) with JSON.stringify in gateway error handler ([8dcaa04](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/8dcaa04499ef0dbacb988b9a0acddbf463ac68be))

## [4.8.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.7.0...v4.8.0) (2026-05-24)

### cascade
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@ Explore advanced features and configurations:
| 🔐 **Authentication** | JWT dual-token auth (8 endpoints) — access + refresh tokens, cookie mode, server-side revocation | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/authentication.md) |
| 🛡️ **Authorization** | Role-based access control | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/authorization.md) |
| 📡 **WebSockets** | Socket.IO integration for routes | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/websockets.md) |
| 🟢 **Presence** | Real-time online/offline tracking for WebSocket users | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/presence.md) |
| 🗂️ **Route Configuration** | All route options: DTOs, interceptors, etc. | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/route-config.md) |
| 🔁 **Callbacks** | beforeSave / afterSave hooks, typed contexts, user access, audit trails | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/callbacks.md) |
| 🎛️ **Controller Configuration** | All `controllerOptions` and `forFeature` options | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/controller-config.md) |
Expand Down
250 changes: 250 additions & 0 deletions README/presence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# Presence Management

`DynamicApiPresenceModule` tracks the **online/offline status** of authenticated WebSocket users in real time.
It supports multi-tab connections (a user stays online until every socket disconnects) and an optional HTTP endpoint.

---

## Table of Contents

- [Quick start](#quick-start)
- [Module options](#module-options)
- [Adapters](#adapters)
- [InMemoryPresenceAdapter](#inmemorypresenceadapter)
- [RedisPresenceAdapter](#redispresenceadapter)
- [WebSocket events](#websocket-events)
- [HTTP endpoint (optional)](#http-endpoint-optional)
- [Dependency injection](#dependency-injection)
- [Architecture notes](#architecture-notes)

---

## Quick start

```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { DynamicApiModule, DynamicApiPresenceModule, enableDynamicAPIWebSockets } from 'mongodb-dynamic-api';

@Module({
imports: [
DynamicApiModule.forRoot('mongodb://localhost/mydb', {
useAuth: {
userEntity: UserEntity,
jwt: { secret: 'my-secret', expiresIn: '1h' },
login: {},
webSocket: true,
},
}),

// ← register presence alongside DynamicApiModule
DynamicApiPresenceModule.register({
adapter: 'memory', // 'memory' | 'redis'
enableController: true, // expose GET /presence
}),
],
})
export class AppModule {}
```

In your bootstrap function, call `enableDynamicAPIWebSockets` so the `SocketAdapter` decodes JWTs and populates `socket.user`:

```typescript
// main.ts
import { enableDynamicAPIWebSockets } from 'mongodb-dynamic-api';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
enableDynamicAPIWebSockets(app); // ← required for presence to work
await app.listen(3000);
}
```

---

## Module options

```typescript
interface PresenceRegisterOptions {
/**
* Storage backend.
* - `'memory'` — in-process Map. Single-instance / dev use.
* - `'redis'` — ioredis. Multi-instance / production.
*/
adapter: 'memory' | 'redis';

/**
* Redis connection URL — required when `adapter === 'redis'`.
* @example 'redis://localhost:6379'
*/
redisUrl?: string;

/**
* TTL in seconds applied to every Redis key on write.
* Acts as an automatic heartbeat safety-net (stale entries from crashed
* processes expire). Refreshed on every `setOnline` call.
* @default 60
*/
redisTtlSeconds?: number;

/**
* Expose `GET /presence[?room=<roomId>]` HTTP endpoint.
* @default false
*/
enableController?: boolean;
}
```

### Example — Redis with custom TTL

```typescript
DynamicApiPresenceModule.register({
adapter: 'redis',
redisUrl: process.env.REDIS_URL,
redisTtlSeconds: 90,
enableController: true,
})
```

---

## Adapters

Both adapters implement the same `PresenceAdapter` interface:

```typescript
interface PresenceAdapter {
/** Mark userId + socketId as online, optionally bound to a room. */
setOnline(userId: string, socketId: string, room?: string): Promise<void>;

/** Remove a socket from the user's online set. */
setOffline(userId: string, socketId: string): Promise<void>;

/** Returns true when the user has at least one active socket. */
isOnline(userId: string): Promise<boolean>;

/** Returns online user IDs, filtered by room when provided. */
getOnlineUserIds(room?: string): Promise<string[]>;

/** Returns the number of active sockets for the user (multi-tab). */
getSocketCount(userId: string): Promise<number>;
}
```

### InMemoryPresenceAdapter

In-process. No external dependencies. Best for single-instance deployments and development.

- State is lost on process restart.
- Multi-tab: each `(userId, socketId)` pair is tracked independently.

### RedisPresenceAdapter

Redis-backed via [ioredis](https://github.com/redis/ioredis). Suitable for distributed / multi-instance deployments.

**Key schema:**

| Key | Type | Content |
|-----|------|---------|
| `presence:sockets:{userId}` | SET | Active socket IDs |
| `presence:room:{roomId}` | SET | User IDs in a room |

- Every key gets an `EXPIRE` (default: 60 s) refreshed on each `setOnline`.
This is a safety-net: if a process crashes, stale entries expire automatically.
- `setOffline` uses an atomic **Lua script** (SREM + DEL-if-empty) to avoid race conditions in multi-instance deployments.

---

## WebSocket events

The `PresenceGateway` shares the same socket.io namespace as the other DynamicApi gateways (no second server).

| Event | Direction | Payload | Description |
|-------|-----------|---------|-------------|
| `user:online` | Server → all clients | `{ userId: string }` | Emitted when an authenticated user connects. |
| `user:offline` | Server → all clients | `{ userId: string }` | Emitted when a user's **last** socket disconnects. |

### Client-side example

```typescript
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
query: { accessToken: '<your-jwt>' },
});

socket.on('user:online', ({ userId }) => console.log(`${userId} is online`));
socket.on('user:offline', ({ userId }) => console.log(`${userId} went offline`));
```

---

## HTTP endpoint (optional)

Enable with `enableController: true` in `register()`.

> **Note:** The endpoint is decorated with `@Public()` internally, so it remains accessible even when the `DynamicApiJwtAuthGuard` global guard is active — no JWT token is required to query it.

### `GET /presence`

Returns all online user IDs.

```json
{ "onlineUserIds": ["64a1...", "64b2..."] }
```

### `GET /presence?room=<roomId>`

Returns online user IDs in a specific room.

```json
{ "onlineUserIds": ["64a1..."] }
```

---

## Dependency injection

Inject the adapter anywhere in your application using the `DYNAMIC_API_PRESENCE_ADAPTER` token:

```typescript
import { Inject, Injectable } from '@nestjs/common';
import {
DYNAMIC_API_PRESENCE_ADAPTER,
PresenceAdapter,
} from 'mongodb-dynamic-api';

@Injectable()
export class ChatService {
constructor(
@Inject(DYNAMIC_API_PRESENCE_ADAPTER)
private readonly presence: PresenceAdapter,
) {}

async getOnlineFriends(roomId: string): Promise<string[]> {
return this.presence.getOnlineUserIds(roomId);
}

async isUserOnline(userId: string): Promise<boolean> {
return this.presence.isOnline(userId);
}
}
```

---

## Architecture notes

1. **JWT decoding** is performed by `SocketAdapter` (wired by `enableDynamicAPIWebSockets`). It sets `socket.user` before any gateway listener fires.

2. **Connection tracking** is handled entirely by `PresenceGateway.afterInit(server)`:
- Registers a `connection` listener via `server.on('connection', ...)`.
- Each socket connect → `setOnline` → emit `user:online`.
- Each socket disconnect → `setOffline` → if `getSocketCount === 0` → emit `user:offline`.

3. **Same namespace** (Option B): `PresenceGateway` uses the same `@WebSocketGateway(options)` as the other DynamicApi gateways, so no second socket server is created.

4. **Multi-tab** support is built-in: a user is tracked per `(userId, socketId)` pair. `isOnline` / `user:offline` events only trigger when the last socket disconnects.

5. **Redis atomicity**: `setOffline` uses a Lua script to SREM + DEL-if-empty atomically, preventing race conditions between concurrent disconnects on different instances.

2 changes: 2 additions & 0 deletions libs/dynamic-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export * from './interfaces';
export * from './logger';
export * from './mixins';
export * from './models';
export * from './modules';
export * from './modules/presence';
export * from './routes';
export * from './services';
export * from './utils';
Expand Down
63 changes: 63 additions & 0 deletions libs/dynamic-api/src/interfaces/dynamic-api-presence.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/** Injection token for the PresenceAdapter in NestJS DI. */
const DYNAMIC_API_PRESENCE_ADAPTER = Symbol('DYNAMIC_API_PRESENCE_ADAPTER');

/**
* Contract every presence adapter must satisfy.
* All methods are async to support both in-memory and network-backed (Redis) implementations.
*/
interface PresenceAdapter {
/** Mark userId + socketId as online, optionally bound to a room. */
setOnline(userId: string, socketId: string, room?: string): Promise<void>;
/** Remove a socket from the user's online set. */
setOffline(userId: string, socketId: string): Promise<void>;
/** Return true when the user has at least one active socket. */
isOnline(userId: string): Promise<boolean>;
/** Return the list of online user IDs, filtered by room when provided. */
getOnlineUserIds(room?: string): Promise<string[]>;
/** Return the number of active sockets for a given user (multi-tab support). */
getSocketCount(userId: string): Promise<number>;
}

/**
* Options passed to `DynamicApiPresenceModule.register()`.
*/
interface PresenceRegisterOptions {
/** Backend to use — 'memory' (default) or 'redis'. */
adapter: 'memory' | 'redis';
/**
* Redis connection URL — required when `adapter === 'redis'`.
* @example 'redis://localhost:6379'
*/
redisUrl?: string;
/**
* TTL in seconds applied to every Redis key on write.
* Prevents stale entries when a process crashes without emitting disconnect.
* @default 60
*/
redisTtlSeconds?: number;
/**
* Expose a `GET /presence` HTTP endpoint.
* Accepts optional `?room=<roomId>` query param.
* @default false
*/
enableController?: boolean;
}

/** Shape of the payload emitted on `user:online` / `user:offline` events. */
interface PresenceEventPayload {
userId: string;
}

/** Shape of the `GET /presence` HTTP response. */
interface PresenceResponse {
onlineUserIds: string[];
}

export type {
PresenceAdapter,
PresenceRegisterOptions,
PresenceEventPayload,
PresenceResponse,
};
export { DYNAMIC_API_PRESENCE_ADAPTER };

1 change: 1 addition & 0 deletions libs/dynamic-api/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './dynamic-api-service-callback.interface';
export * from './dynamic-api-service-provider.interface';
export * from './dynamic-api-swagger-options.type';
export * from './dynamic-api-web-socket.interface';
export * from './dynamic-api-presence.interface';
Loading