Skip to content
markdown
# WebSocket & Real-Time Socket Integration

QuickModel is an ideal serialization and validation layer for **real-time socket communications**. It guarantees that every message crossing the wire is correctly typed, validated, and stripped of unknown fields — protecting both client and server from malformed or malicious payloads.

## Key Patterns

| Concern                     | QuickModel solution                           |
| --------------------------- | --------------------------------------------- |
| Serialize before sending    | `dto.$qSerialize()` → JSON-safe object        |
| Reconstruct on receive      | `new Dto(JSON.parse(raw))`                    |
| Complex types over the wire | `Date`, `Set`, `Map`, `BigInt` roundtrip      |
| Unknown field stripping     | `unknownPropertyPolicy: 'strip'`              |
| Validate after receive      | `qCheckRules(dto)` / `qCheckRulesAsync(dto)`  |
| Computed enrichment         | `@QComputed()` available after reconstruction |
| Incremental patch           | `existing.$qCopy(patch)` for delta updates    |
| Batch events                | `Dto.createMany(array).instances`             |

## Supported Environments

This guide covers five real-time socket environments, all verified by the integration test suite:

1. [Native WebSocket (browser + Node.js)](#native-websocket)
2. [Socket.IO](#socketio)
3. [Server-Sent Events (SSE)](#server-sent-events-sse)
4. [uWebSockets.js (binary ArrayBuffer)](#uwebsocketsjs)
5. [STOMP over WebSocket](#stomp-over-websocket)

---

## Model Setup

Define your models once. They work identically across all socket environments.

```typescript
import { QModel, Quick, QRule, QField, QComputed } from 'quickmodel';

interface IChatMessage {
	id: string;
	roomId: string;
	author: string;
	text: string;
	sentAt: Date;
	mentions: Set<string>;
	metadata: Map<string, string>;
}

@Quick(
	{
		id: 'string',
		roomId: 'string',
		author: 'string',
		text: 'string',
		sentAt: Date,
		mentions: Set,
		metadata: Map,
	},
	{ unknownPropertyPolicy: 'strip', coercionStrategy: 'loose' }
)
class ChatMessageDto extends QModel<IChatMessage> {
	@QField({ label: 'ID', required: true })
	@QRule((val: string) => val.length > 0, 'ID is required')
	declare id: string;

	@QField({ label: 'Author', required: true })
	@QRule((val: string) => val.length >= 2, 'Author name too short')
	declare author: string;

	@QField({ label: 'Text', required: true })
	@QRule(
		(val: string) => val.length > 0 && val.length <= 4000,
		'Invalid text length'
	)
	declare text: string;

	@QField({ label: 'Sent At' })
	declare sentAt: Date;

	@QField({ label: 'Mentions' })
	declare mentions: Set<string>;

	@QField({ label: 'Metadata' })
	declare metadata: Map<string, string>;

	@QComputed()
	get wordCount(): number {
		return this.text.split(/\s+/).filter(Boolean).length;
	}

	@QComputed()
	get hasMentions(): boolean {
		return this.mentions.size > 0;
	}
}
```

---

## Native WebSocket

Works with the browser `WebSocket` API and Node.js [`ws`](https://github.com/websockets/ws) / [`bun`](https://bun.sh/docs/api/websockets).

### Client — Sending

```typescript
const ws = new WebSocket('wss://example.com/chat');

const msg = new ChatMessageDto({
	id: crypto.randomUUID(),
	roomId: 'room-42',
	author: 'Alice',
	text: 'Hello WebSocket world!',
	sentAt: new Date(),
	mentions: new Set(['Bob']),
	metadata: new Map([['source', 'web']]),
});

// $qSerialize() converts Date → ISO string, Set → array, Map → object, BigInt → string
ws.send(JSON.stringify(msg.$qSerialize()));
```

### Server — Receiving (Node.js `ws`)

```typescript
import { WebSocketServer } from 'ws';
import { qCheckRules } from 'quickmodel/forms';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (socket) => {
	socket.on('message', (raw) => {
		const dto = new ChatMessageDto(JSON.parse(raw.toString()));
		const { valid, errors } = qCheckRules(dto);

		if (!valid) {
			socket.send(JSON.stringify({ type: 'error', errors }));
			return;
		}

		// dto.sentAt is a real Date, dto.mentions is a real Set
		console.log(`[${dto.sentAt.toISOString()}] ${dto.author}: ${dto.text}`);
		console.log('Words:', dto.wordCount);

		// Broadcast to all clients
		wss.clients.forEach((client) => {
			if (client.readyState === 1) {
				client.send(JSON.stringify(dto.$qSerialize()));
			}
		});
	});
});
```

### Bidirectional (server → client)

```typescript
// Server pushes presence events
const presenceDto = new PresenceEventDto({
	userId: 'u-99',
	status: 'online',
	lastSeen: new Date(),
	activeRooms: new Set(['room-42']),
});
socket.send(JSON.stringify(presenceDto.$qSerialize()));

// Client receives and reconstructs
ws.onmessage = (evt) => {
	const data = JSON.parse(evt.data);
	const presence = new PresenceEventDto(data);
	// presence.lastSeen is a real Date ✓
	// presence.activeRooms is a real Set ✓
	updateUI(presence);
};
```

---

## Socket.IO

QuickModel integrates with [Socket.IO](https://socket.io) as the payload layer on both client and server, across namespaces and rooms.

### Server Setup

```typescript
import { Server } from 'socket.io';
import { qCheckRules } from 'quickmodel/forms';

const io = new Server(httpServer);

io.on('connection', (socket) => {
	// Reconstruct model from raw event payload
	socket.on('chat:message', (raw, ack) => {
		const dto = new ChatMessageDto(raw);
		const validation = qCheckRules(dto);

		if (!validation.valid) {
			ack?.({ ok: false, errors: validation.errors });
			return;
		}

		// Emit to room — $qSerialize() ensures JSON-safe payload
		io.to(dto.roomId).emit('chat:message', dto.$qSerialize());
		ack?.({ ok: true, id: dto.id });
	});

	socket.on('user:presence', (raw, ack) => {
		const dto = new PresenceEventDto(raw);
		const result = qCheckRules(dto);
		ack?.(result);
		if (result.valid) {
			io.emit('presence:update', dto.$qSerialize());
		}
	});
});
```

### Client Setup

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

const socket = io('wss://example.com');

// Emit with acknowledgement
const msg = new ChatMessageDto({ ... });
socket.emit('chat:message', msg.$qSerialize(), (response) => {
	if (!response.ok) {
		console.error('Validation errors:', response.errors);
	}
});

// Receive and reconstruct
socket.on('chat:message', (raw) => {
	const dto = new ChatMessageDto(raw);
	// dto.sentAt → Date, dto.mentions → Set ✓
	appendMessage(dto);
});

socket.on('presence:update', (raw) => {
	const dto = new PresenceEventDto(raw);
	updatePresence(dto);
});
```

### Incremental Updates with `$qCopy()`

Use `$qCopy()` to apply delta patches from socket events — creates a new immutable instance:

```typescript
let currentUser: PresenceEventDto | null = null;

socket.on('presence:patch', (patch) => {
	if (currentUser) {
		currentUser = currentUser.$qCopy(patch);
	} else {
		currentUser = new PresenceEventDto(patch);
	}
	renderPresence(currentUser);
});
```

---

## Server-Sent Events (SSE)

For unidirectional server → client streams (live feeds, dashboards, notifications).

### Server — Express / Hono

```typescript
import express from 'express';

const app = express();

app.get('/events/stocks', (req, res) => {
	res.setHeader('Content-Type', 'text/event-stream');
	res.setHeader('Cache-Control', 'no-cache');
	res.setHeader('Connection', 'keep-alive');

	const sendTick = (tick: StockTickDto) => {
		res.write(`event: stock:tick\n`);
		res.write(`data: ${JSON.stringify(tick.$qSerialize())}\n\n`);
	};

	const interval = setInterval(() => {
		const tick = new StockTickDto({
			symbol: 'AAPL',
			price: Math.random() * 200 + 150,
			volume: BigInt(Math.floor(Math.random() * 1_000_000)),
			timestamp: new Date(),
			change: (Math.random() - 0.5) * 5,
			high: 200,
			low: 150,
		});
		sendTick(tick);
	}, 1000);

	req.on('close', () => clearInterval(interval));
});
```

### Client — EventSource

```typescript
const eventSource = new EventSource('/events/stocks');

eventSource.addEventListener('stock:tick', (evt) => {
	const dto = new StockTickDto(JSON.parse(evt.data));
	// dto.timestamp → Date, dto.volume → BigInt ✓
	updateChart(dto);
});

// Multiple event types (multiplexed SSE)
eventSource.addEventListener('chat:message', (evt) => {
	const dto = new ChatMessageDto(JSON.parse(evt.data));
	appendMessage(dto);
});

eventSource.addEventListener('user:presence', (evt) => {
	const dto = new PresenceEventDto(JSON.parse(evt.data));
	updatePresence(dto);
});
```

---

## uWebSockets.js

[uWebSockets.js](https://github.com/uNetworking/uWebSockets.js) delivers the highest WebSocket throughput. Messages arrive as `ArrayBuffer`. QuickModel fits in via `TextEncoder`/`TextDecoder`.

```typescript
import { App } from 'uWebSockets.js';
import { qCheckRules } from 'quickmodel/forms';

const encoder = new TextEncoder();
const decoder = new TextDecoder();

App()
	.ws('/chat', {
		message(ws, message) {
			const raw = decoder.decode(message);
			const dto = new ChatMessageDto(JSON.parse(raw));
			const { valid, errors } = qCheckRules(dto);

			if (!valid) {
				ws.send(
					encoder.encode(JSON.stringify({ type: 'error', errors }))
				);
				return;
			}

			// Broadcast to all subscribers — binary=false for text frames
			ws.publish(
				'room:' + dto.roomId,
				JSON.stringify(dto.$qSerialize()),
				false
			);
		},

		open(ws) {
			ws.subscribe('room:general');
		},
	})
	.listen(9001, () => console.log('uWS listening on 9001'));
```

### Client

```typescript
const ws = new WebSocket('ws://localhost:9001/chat');

const msg = new ChatMessageDto({ ... });
ws.send(JSON.stringify(msg.$qSerialize())); // Plain text frame

ws.onmessage = (evt) => {
	const dto = new ChatMessageDto(JSON.parse(evt.data));
	appendMessage(dto);
};
```

---

## STOMP over WebSocket

[STOMP](https://stomp.github.io/) (Simple Text Oriented Messaging Protocol) layers pub/sub semantics over WebSocket, commonly used with ActiveMQ, RabbitMQ, and Spring.

### With `@stomp/stompjs`

```typescript
import { Client } from '@stomp/stompjs';
import { qCheckRules } from 'quickmodel/forms';

const stompClient = new Client({
	brokerURL: 'ws://localhost:15674/ws',
	onConnect: () => {
		// Subscribe to a topic
		stompClient.subscribe('/topic/chat.general', (frame) => {
			const dto = new ChatMessageDto(JSON.parse(frame.body));
			const { valid } = qCheckRules(dto);
			if (valid) appendMessage(dto);
		});

		stompClient.subscribe('/topic/presence', (frame) => {
			const dto = new PresenceEventDto(JSON.parse(frame.body));
			updatePresence(dto);
		});
	},
});

stompClient.activate();

// Send a message
const msg = new ChatMessageDto({ ... });
stompClient.publish({
	destination: '/app/chat.general',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify(msg.$qSerialize()),
});
```

### With SockJS fallback

```typescript
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const stompClient = new Client({
	webSocketFactory: () => new SockJS('/stomp'),
	onConnect: () => {
		stompClient.subscribe('/topic/chat.general', (frame) => {
			const dto = new ChatMessageDto(JSON.parse(frame.body));
			appendMessage(dto);
		});
	},
});
```

---

## Security Best Practices

### 1. Always use `unknownPropertyPolicy: 'strip'`

```typescript
@Quick(
	{ id: 'string', author: 'string', text: 'string' },
	{ unknownPropertyPolicy: 'strip' } // ← removes isAdmin, _token, etc.
)
class ChatMessageDto extends QModel<IChatMessage> { ... }
```

A malicious WebSocket payload like:

```json
{
	"id": "x1",
	"author": "Hacker",
	"text": "Normal message",
	"isAdmin": true,
	"_internalToken": "secret",
	"role": "superadmin"
}
```

is automatically sanitized — only declared fields survive reconstruction.

### 2. Validate every incoming message

```typescript
socket.on('message', (raw) => {
	let dto: ChatMessageDto;
	try {
		dto = new ChatMessageDto(JSON.parse(raw));
	} catch {
		socket.terminate(); // Malformed JSON
		return;
	}

	const { valid, errors } = qCheckRules(dto);
	if (!valid) {
		socket.send(JSON.stringify({ type: 'validation_error', errors }));
		return;
	}

	// Safe to process
});
```

### 3. Guard against DoS via oversized payloads

```typescript
@QRule((val: string) => val.length <= 4000, 'Message too long')
declare text: string;

@QRule((val: bigint) => val >= 0n && val <= BigInt(Number.MAX_SAFE_INTEGER), 'Volume out of range')
declare volume: bigint;
```

---

## Batch Events with `createMany()`

```typescript
// Server receives a burst of stock ticks
socket.on('stock:batch', (rawArray) => {
	const { instances, errors } = StockTickDto.createMany(rawArray);

	if (errors.length > 0) {
		console.warn('Skipped invalid ticks:', errors.length);
	}

	// instances → StockTickDto[], all with proper Date, BigInt, etc.
	for (const tick of instances) {
		updateOrderBook(tick);
	}
});

// Client sends a batch
const { instances } = StockTickDto.createMany(rawTicks);
socket.emit(
	'stock:batch',
	instances.map((dto) => dto.$qSerialize())
);
```

---

## BigInt over the Wire

`BigInt` cannot be natively serialized by `JSON.stringify`. QuickModel handles this automatically:

```typescript
// Serialization: BigInt → string  (via $qSerialize())
tick.$qSerialize();
// { symbol: 'AAPL', volume: '987654321000', ... }

// Deserialization: string → BigInt  (via new Dto())
const reconstructed = new StockTickDto(serialized);
typeof reconstructed.volume; // 'bigint' ✓
```

> **Tip:** If you need to interop with clients that don't use QuickModel, document your wire format: `volume` is always a numeric string.

---

## Testing

The full integration test suite for WebSocket environments is at:

- `tests/integration/external/websocket-simulation.test.ts`

It covers all 5 environments described above (26 tests), including security attack vectors.

```bash
bun test tests/integration/external/websocket-simulation.test.ts
# 26 pass, 0 fail
```