@objectstack/service-realtime 4.0.3 → 4.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/dist/index.cjs +6 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -2132
- package/dist/index.d.ts +1 -2132
- package/dist/index.js +4 -96
- package/dist/index.js.map +1 -1
- package/package.json +32 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -160
- package/src/in-memory-realtime-adapter.test.ts +0 -242
- package/src/in-memory-realtime-adapter.ts +0 -154
- package/src/index.ts +0 -7
- package/src/objects/index.ts +0 -9
- package/src/objects/sys-presence.object.test.ts +0 -73
- package/src/objects/sys-presence.object.ts +0 -120
- package/src/realtime-service-plugin.ts +0 -67
- package/tsconfig.json +0 -17
package/README.md
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# @objectstack/service-realtime
|
|
2
|
+
|
|
3
|
+
Realtime Service for ObjectStack — implements `IRealtimeService` with WebSocket and in-memory pub/sub.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **WebSocket Support**: Real-time bidirectional communication
|
|
8
|
+
- **Pub/Sub Pattern**: Subscribe to channels and receive updates
|
|
9
|
+
- **Room-Based Architecture**: Organize connections into rooms
|
|
10
|
+
- **Presence Tracking**: Track online users and their status
|
|
11
|
+
- **Message Broadcasting**: Send messages to all connections or specific rooms
|
|
12
|
+
- **Event Streaming**: Stream database changes and system events
|
|
13
|
+
- **Auto-Reconnection**: Client auto-reconnects on connection loss
|
|
14
|
+
- **Type-Safe**: Full TypeScript support for events and messages
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @objectstack/service-realtime
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Basic Usage
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { defineStack } from '@objectstack/spec';
|
|
26
|
+
import { ServiceRealtime } from '@objectstack/service-realtime';
|
|
27
|
+
|
|
28
|
+
const stack = defineStack({
|
|
29
|
+
services: [
|
|
30
|
+
ServiceRealtime.configure({
|
|
31
|
+
port: 3001,
|
|
32
|
+
path: '/ws',
|
|
33
|
+
}),
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface RealtimeServiceConfig {
|
|
42
|
+
/** WebSocket server port (default: 3001) */
|
|
43
|
+
port?: number;
|
|
44
|
+
|
|
45
|
+
/** WebSocket path (default: '/ws') */
|
|
46
|
+
path?: string;
|
|
47
|
+
|
|
48
|
+
/** Enable CORS (default: true) */
|
|
49
|
+
cors?: boolean;
|
|
50
|
+
|
|
51
|
+
/** Maximum connections per user (default: 10) */
|
|
52
|
+
maxConnectionsPerUser?: number;
|
|
53
|
+
|
|
54
|
+
/** Ping interval in ms (default: 30000) */
|
|
55
|
+
pingInterval?: number;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Service API (Server-Side)
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Get realtime service
|
|
63
|
+
const realtime = kernel.getService<IRealtimeService>('realtime');
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Broadcasting
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// Broadcast to all connected clients
|
|
70
|
+
await realtime.broadcast({
|
|
71
|
+
event: 'notification',
|
|
72
|
+
data: { message: 'System update in 5 minutes' },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Broadcast to specific room
|
|
76
|
+
await realtime.broadcastToRoom('opportunity:123', {
|
|
77
|
+
event: 'record_updated',
|
|
78
|
+
data: { recordId: '123', field: 'stage', value: 'closed_won' },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Broadcast to specific user
|
|
82
|
+
await realtime.broadcastToUser('user:456', {
|
|
83
|
+
event: 'mention',
|
|
84
|
+
data: { commentId: 'comment:789', mentionedBy: 'user:123' },
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Channel Management
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// Join a channel (room)
|
|
92
|
+
await realtime.join(connectionId, 'opportunity:123');
|
|
93
|
+
|
|
94
|
+
// Leave a channel
|
|
95
|
+
await realtime.leave(connectionId, 'opportunity:123');
|
|
96
|
+
|
|
97
|
+
// Get all connections in a channel
|
|
98
|
+
const connections = await realtime.getChannelConnections('opportunity:123');
|
|
99
|
+
|
|
100
|
+
// Get all channels for a connection
|
|
101
|
+
const channels = await realtime.getConnectionChannels(connectionId);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Presence
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// Set user presence
|
|
108
|
+
await realtime.setPresence('user:456', {
|
|
109
|
+
status: 'online',
|
|
110
|
+
currentPage: '/opportunity/123',
|
|
111
|
+
lastActive: new Date(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Get user presence
|
|
115
|
+
const presence = await realtime.getPresence('user:456');
|
|
116
|
+
|
|
117
|
+
// Get all online users
|
|
118
|
+
const onlineUsers = await realtime.getOnlineUsers();
|
|
119
|
+
|
|
120
|
+
// Get users in a specific channel
|
|
121
|
+
const channelUsers = await realtime.getChannelPresence('opportunity:123');
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Client-Side Usage
|
|
125
|
+
|
|
126
|
+
### React Hook
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { useRealtime } from '@objectstack/client-react';
|
|
130
|
+
|
|
131
|
+
function OpportunityDetails({ id }: { id: string }) {
|
|
132
|
+
const { subscribe, send, isConnected } = useRealtime();
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
// Subscribe to record updates
|
|
136
|
+
const unsubscribe = subscribe(`opportunity:${id}`, (event) => {
|
|
137
|
+
if (event.type === 'record_updated') {
|
|
138
|
+
console.log('Record updated:', event.data);
|
|
139
|
+
// Update UI
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return unsubscribe;
|
|
144
|
+
}, [id]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div>
|
|
148
|
+
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### JavaScript Client
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { RealtimeClient } from '@objectstack/client';
|
|
158
|
+
|
|
159
|
+
const client = new RealtimeClient({
|
|
160
|
+
url: 'ws://localhost:3001/ws',
|
|
161
|
+
auth: {
|
|
162
|
+
token: 'your-auth-token',
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Connect
|
|
167
|
+
await client.connect();
|
|
168
|
+
|
|
169
|
+
// Subscribe to a channel
|
|
170
|
+
client.subscribe('opportunity:123', (event) => {
|
|
171
|
+
console.log('Received event:', event);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Send a message
|
|
175
|
+
client.send('typing', {
|
|
176
|
+
recordId: '123',
|
|
177
|
+
userId: 'user:456',
|
|
178
|
+
isTyping: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Disconnect
|
|
182
|
+
await client.disconnect();
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Advanced Features
|
|
186
|
+
|
|
187
|
+
### Event Streaming
|
|
188
|
+
|
|
189
|
+
Stream database changes in real-time:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Server-side: Stream record changes
|
|
193
|
+
realtime.streamRecordChanges('opportunity', {
|
|
194
|
+
onInsert: async (record) => {
|
|
195
|
+
await realtime.broadcast({
|
|
196
|
+
event: 'record_created',
|
|
197
|
+
data: { object: 'opportunity', record },
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
onUpdate: async (record, changes) => {
|
|
201
|
+
await realtime.broadcastToRoom(`opportunity:${record.id}`, {
|
|
202
|
+
event: 'record_updated',
|
|
203
|
+
data: { recordId: record.id, changes },
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
onDelete: async (recordId) => {
|
|
207
|
+
await realtime.broadcast({
|
|
208
|
+
event: 'record_deleted',
|
|
209
|
+
data: { object: 'opportunity', recordId },
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Private Channels
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Server-side: Authorize private channel access
|
|
219
|
+
realtime.authorizeChannel = async (userId, channel) => {
|
|
220
|
+
if (channel.startsWith('user:')) {
|
|
221
|
+
// Only allow users to join their own private channel
|
|
222
|
+
return channel === `user:${userId}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (channel.startsWith('opportunity:')) {
|
|
226
|
+
// Check if user has access to the opportunity
|
|
227
|
+
const opportunityId = channel.split(':')[1];
|
|
228
|
+
return await hasAccess(userId, 'opportunity', opportunityId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Typing Indicators
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// Client sends typing event
|
|
239
|
+
client.send('typing', {
|
|
240
|
+
recordId: '123',
|
|
241
|
+
userId: 'user:456',
|
|
242
|
+
isTyping: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Server broadcasts to room
|
|
246
|
+
realtime.on('typing', async (connectionId, data) => {
|
|
247
|
+
await realtime.broadcastToRoom(`opportunity:${data.recordId}`, {
|
|
248
|
+
event: 'user_typing',
|
|
249
|
+
data: { userId: data.userId, isTyping: data.isTyping },
|
|
250
|
+
}, { exclude: [connectionId] }); // Don't send back to sender
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Other clients receive typing notification
|
|
254
|
+
client.subscribe('opportunity:123', (event) => {
|
|
255
|
+
if (event.type === 'user_typing') {
|
|
256
|
+
showTypingIndicator(event.data.userId, event.data.isTyping);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Live Cursor Tracking
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
// Client sends cursor position
|
|
265
|
+
client.send('cursor', {
|
|
266
|
+
recordId: '123',
|
|
267
|
+
x: 450,
|
|
268
|
+
y: 200,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Server broadcasts to room
|
|
272
|
+
realtime.on('cursor', async (connectionId, data) => {
|
|
273
|
+
const user = await getConnectionUser(connectionId);
|
|
274
|
+
|
|
275
|
+
await realtime.broadcastToRoom(`opportunity:${data.recordId}`, {
|
|
276
|
+
event: 'cursor_moved',
|
|
277
|
+
data: {
|
|
278
|
+
userId: user.id,
|
|
279
|
+
userName: user.name,
|
|
280
|
+
x: data.x,
|
|
281
|
+
y: data.y,
|
|
282
|
+
},
|
|
283
|
+
}, { exclude: [connectionId] });
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Collaborative Editing
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// Operational Transform (OT) for collaborative editing
|
|
291
|
+
client.send('edit', {
|
|
292
|
+
documentId: '123',
|
|
293
|
+
operation: {
|
|
294
|
+
type: 'insert',
|
|
295
|
+
position: 42,
|
|
296
|
+
text: 'Hello',
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
realtime.on('edit', async (connectionId, data) => {
|
|
301
|
+
// Apply operation transform
|
|
302
|
+
const transformedOp = await applyOT(data.operation);
|
|
303
|
+
|
|
304
|
+
// Broadcast to all editors
|
|
305
|
+
await realtime.broadcastToRoom(`document:${data.documentId}`, {
|
|
306
|
+
event: 'operation',
|
|
307
|
+
data: transformedOp,
|
|
308
|
+
}, { exclude: [connectionId] });
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Integration with ObjectStack Features
|
|
313
|
+
|
|
314
|
+
### Feed Updates
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// When a comment is added
|
|
318
|
+
feed.on('comment_added', async (comment) => {
|
|
319
|
+
await realtime.broadcastToRoom(`${comment.object}:${comment.recordId}`, {
|
|
320
|
+
event: 'feed_update',
|
|
321
|
+
data: { type: 'comment', comment },
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Workflow Status
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// When a flow step completes
|
|
330
|
+
automation.on('step_completed', async (execution) => {
|
|
331
|
+
await realtime.broadcastToUser(execution.userId, {
|
|
332
|
+
event: 'flow_progress',
|
|
333
|
+
data: {
|
|
334
|
+
flowId: execution.flowId,
|
|
335
|
+
step: execution.currentStep,
|
|
336
|
+
progress: execution.progress,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Analytics Dashboard
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// Stream real-time metrics
|
|
346
|
+
setInterval(async () => {
|
|
347
|
+
const metrics = await analytics.getCurrentMetrics();
|
|
348
|
+
|
|
349
|
+
await realtime.broadcastToRoom('dashboard:sales', {
|
|
350
|
+
event: 'metrics_update',
|
|
351
|
+
data: metrics,
|
|
352
|
+
});
|
|
353
|
+
}, 5000); // Every 5 seconds
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Connection Events
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Server-side event handlers
|
|
360
|
+
realtime.on('connection', async (connectionId, userId) => {
|
|
361
|
+
console.log(`User ${userId} connected (${connectionId})`);
|
|
362
|
+
|
|
363
|
+
// Set initial presence
|
|
364
|
+
await realtime.setPresence(userId, { status: 'online' });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
realtime.on('disconnection', async (connectionId, userId) => {
|
|
368
|
+
console.log(`User ${userId} disconnected`);
|
|
369
|
+
|
|
370
|
+
// Update presence
|
|
371
|
+
await realtime.setPresence(userId, {
|
|
372
|
+
status: 'offline',
|
|
373
|
+
lastSeen: new Date(),
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
realtime.on('error', async (connectionId, error) => {
|
|
378
|
+
console.error(`Connection error:`, error);
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Best Practices
|
|
383
|
+
|
|
384
|
+
1. **Channel Organization**: Use namespaced channels (e.g., `object:recordId`)
|
|
385
|
+
2. **Authorization**: Always verify channel access before joining
|
|
386
|
+
3. **Message Size**: Keep messages small (< 10KB)
|
|
387
|
+
4. **Rate Limiting**: Limit message frequency per connection
|
|
388
|
+
5. **Cleanup**: Remove disconnected users from channels
|
|
389
|
+
6. **Heartbeat**: Implement ping/pong for connection health
|
|
390
|
+
7. **Compression**: Enable WebSocket compression for large messages
|
|
391
|
+
|
|
392
|
+
## Performance Considerations
|
|
393
|
+
|
|
394
|
+
- **Scaling**: Use Redis adapter for multi-server deployments
|
|
395
|
+
- **Connection Pooling**: Limit concurrent connections per user
|
|
396
|
+
- **Channel Limits**: Limit channels per connection
|
|
397
|
+
- **Message Batching**: Batch frequent updates to reduce traffic
|
|
398
|
+
- **Binary Protocol**: Use binary for large data transfers
|
|
399
|
+
|
|
400
|
+
## REST API Endpoints
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
POST /api/v1/realtime/broadcast # Broadcast to all
|
|
404
|
+
POST /api/v1/realtime/broadcast/room/:room # Broadcast to room
|
|
405
|
+
POST /api/v1/realtime/broadcast/user/:userId # Broadcast to user
|
|
406
|
+
GET /api/v1/realtime/presence # Get online users
|
|
407
|
+
GET /api/v1/realtime/presence/:userId # Get user presence
|
|
408
|
+
GET /api/v1/realtime/channels/:channel # Get channel connections
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Contract Implementation
|
|
412
|
+
|
|
413
|
+
Implements `IRealtimeService` from `@objectstack/spec/contracts`:
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
interface IRealtimeService {
|
|
417
|
+
broadcast(message: Message): Promise<void>;
|
|
418
|
+
broadcastToRoom(room: string, message: Message): Promise<void>;
|
|
419
|
+
broadcastToUser(userId: string, message: Message): Promise<void>;
|
|
420
|
+
join(connectionId: string, channel: string): Promise<void>;
|
|
421
|
+
leave(connectionId: string, channel: string): Promise<void>;
|
|
422
|
+
setPresence(userId: string, presence: PresenceData): Promise<void>;
|
|
423
|
+
getPresence(userId: string): Promise<PresenceData | null>;
|
|
424
|
+
getOnlineUsers(): Promise<string[]>;
|
|
425
|
+
on(event: string, handler: EventHandler): void;
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
## License
|
|
430
|
+
|
|
431
|
+
Apache-2.0
|
|
432
|
+
|
|
433
|
+
## See Also
|
|
434
|
+
|
|
435
|
+
- [WebSocket API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
|
|
436
|
+
- [@objectstack/client](../../client/)
|
|
437
|
+
- [@objectstack/client-react](../../client-react/)
|
|
438
|
+
- [Realtime Features Guide](/content/docs/guides/realtime/)
|
package/dist/index.cjs
CHANGED
|
@@ -21,11 +21,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
InMemoryRealtimeAdapter: () => InMemoryRealtimeAdapter,
|
|
24
|
-
RealtimeServicePlugin: () => RealtimeServicePlugin
|
|
25
|
-
SysPresence: () => SysPresence
|
|
24
|
+
RealtimeServicePlugin: () => RealtimeServicePlugin
|
|
26
25
|
});
|
|
27
26
|
module.exports = __toCommonJS(index_exports);
|
|
28
27
|
|
|
28
|
+
// src/realtime-service-plugin.ts
|
|
29
|
+
var import_audit = require("@objectstack/platform-objects/audit");
|
|
30
|
+
|
|
29
31
|
// src/in-memory-realtime-adapter.ts
|
|
30
32
|
var InMemoryRealtimeAdapter = class {
|
|
31
33
|
constructor(options = {}) {
|
|
@@ -101,100 +103,6 @@ var InMemoryRealtimeAdapter = class {
|
|
|
101
103
|
}
|
|
102
104
|
};
|
|
103
105
|
|
|
104
|
-
// src/objects/sys-presence.object.ts
|
|
105
|
-
var import_data = require("@objectstack/spec/data");
|
|
106
|
-
var SysPresence = import_data.ObjectSchema.create({
|
|
107
|
-
namespace: "sys",
|
|
108
|
-
name: "presence",
|
|
109
|
-
label: "Presence",
|
|
110
|
-
pluralLabel: "Presences",
|
|
111
|
-
icon: "wifi",
|
|
112
|
-
isSystem: true,
|
|
113
|
-
description: "Real-time user presence and activity tracking",
|
|
114
|
-
titleFormat: "{user_id} ({status})",
|
|
115
|
-
compactLayout: ["user_id", "status", "last_seen"],
|
|
116
|
-
fields: {
|
|
117
|
-
id: import_data.Field.text({
|
|
118
|
-
label: "Presence ID",
|
|
119
|
-
required: true,
|
|
120
|
-
readonly: true
|
|
121
|
-
}),
|
|
122
|
-
created_at: import_data.Field.datetime({
|
|
123
|
-
label: "Created At",
|
|
124
|
-
defaultValue: "NOW()",
|
|
125
|
-
readonly: true
|
|
126
|
-
}),
|
|
127
|
-
updated_at: import_data.Field.datetime({
|
|
128
|
-
label: "Updated At",
|
|
129
|
-
defaultValue: "NOW()",
|
|
130
|
-
readonly: true
|
|
131
|
-
}),
|
|
132
|
-
user_id: import_data.Field.text({
|
|
133
|
-
label: "User ID",
|
|
134
|
-
required: true,
|
|
135
|
-
searchable: true
|
|
136
|
-
}),
|
|
137
|
-
session_id: import_data.Field.text({
|
|
138
|
-
label: "Session ID",
|
|
139
|
-
required: true
|
|
140
|
-
}),
|
|
141
|
-
status: import_data.Field.select({
|
|
142
|
-
label: "Status",
|
|
143
|
-
required: true,
|
|
144
|
-
defaultValue: "online",
|
|
145
|
-
options: [
|
|
146
|
-
{ value: "online", label: "Online" },
|
|
147
|
-
{ value: "away", label: "Away" },
|
|
148
|
-
{ value: "busy", label: "Busy" },
|
|
149
|
-
{ value: "offline", label: "Offline" }
|
|
150
|
-
]
|
|
151
|
-
}),
|
|
152
|
-
last_seen: import_data.Field.datetime({
|
|
153
|
-
label: "Last Seen",
|
|
154
|
-
required: true,
|
|
155
|
-
defaultValue: "NOW()"
|
|
156
|
-
}),
|
|
157
|
-
current_location: import_data.Field.text({
|
|
158
|
-
label: "Current Location",
|
|
159
|
-
required: false,
|
|
160
|
-
maxLength: 500
|
|
161
|
-
}),
|
|
162
|
-
device: import_data.Field.select({
|
|
163
|
-
label: "Device",
|
|
164
|
-
required: false,
|
|
165
|
-
options: [
|
|
166
|
-
{ value: "desktop", label: "Desktop" },
|
|
167
|
-
{ value: "mobile", label: "Mobile" },
|
|
168
|
-
{ value: "tablet", label: "Tablet" },
|
|
169
|
-
{ value: "other", label: "Other" }
|
|
170
|
-
]
|
|
171
|
-
}),
|
|
172
|
-
custom_status: import_data.Field.text({
|
|
173
|
-
label: "Custom Status",
|
|
174
|
-
required: false,
|
|
175
|
-
maxLength: 255
|
|
176
|
-
}),
|
|
177
|
-
metadata: import_data.Field.json({
|
|
178
|
-
label: "Metadata",
|
|
179
|
-
required: false,
|
|
180
|
-
description: "Arbitrary JSON metadata associated with the presence state (matches PresenceStateSchema.metadata)."
|
|
181
|
-
})
|
|
182
|
-
},
|
|
183
|
-
indexes: [
|
|
184
|
-
{ fields: ["user_id"], unique: false },
|
|
185
|
-
{ fields: ["session_id"], unique: true },
|
|
186
|
-
{ fields: ["status"], unique: false }
|
|
187
|
-
],
|
|
188
|
-
enable: {
|
|
189
|
-
trackHistory: false,
|
|
190
|
-
searchable: false,
|
|
191
|
-
apiEnabled: true,
|
|
192
|
-
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
193
|
-
trash: false,
|
|
194
|
-
mru: false
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
|
|
198
106
|
// src/realtime-service-plugin.ts
|
|
199
107
|
var RealtimeServicePlugin = class {
|
|
200
108
|
constructor(options = {}) {
|
|
@@ -213,7 +121,7 @@ var RealtimeServicePlugin = class {
|
|
|
213
121
|
version: "1.0.0",
|
|
214
122
|
type: "plugin",
|
|
215
123
|
namespace: "sys",
|
|
216
|
-
objects: [SysPresence]
|
|
124
|
+
objects: [import_audit.SysPresence]
|
|
217
125
|
});
|
|
218
126
|
ctx.logger.info("RealtimeServicePlugin: registered in-memory realtime adapter");
|
|
219
127
|
}
|
|
@@ -221,7 +129,6 @@ var RealtimeServicePlugin = class {
|
|
|
221
129
|
// Annotate the CommonJS export names for ESM import in node:
|
|
222
130
|
0 && (module.exports = {
|
|
223
131
|
InMemoryRealtimeAdapter,
|
|
224
|
-
RealtimeServicePlugin
|
|
225
|
-
SysPresence
|
|
132
|
+
RealtimeServicePlugin
|
|
226
133
|
});
|
|
227
134
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/in-memory-realtime-adapter.ts","../src/objects/sys-presence.object.ts","../src/realtime-service-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { RealtimeServicePlugin } from './realtime-service-plugin.js';\nexport type { RealtimeServicePluginOptions } from './realtime-service-plugin.js';\nexport { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';\nexport type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';\nexport { SysPresence } from './objects/index.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IRealtimeService,\n RealtimeEventPayload,\n RealtimeEventHandler,\n RealtimeSubscriptionOptions,\n} from '@objectstack/spec/contracts';\n\n/**\n * Internal subscription entry.\n */\ninterface Subscription {\n id: string;\n channel: string;\n handler: RealtimeEventHandler;\n options?: RealtimeSubscriptionOptions;\n}\n\n/**\n * Configuration options for InMemoryRealtimeAdapter.\n */\nexport interface InMemoryRealtimeAdapterOptions {\n /** Maximum number of subscriptions allowed (0 = unlimited) */\n maxSubscriptions?: number;\n}\n\n/**\n * In-memory pub/sub adapter implementing IRealtimeService.\n *\n * Uses a Map-backed subscription store with channel-based routing.\n * Supports event type and object filtering via subscription options.\n *\n * Suitable for single-process environments, development, and testing.\n * For production multi-instance deployments, use a Redis-backed adapter.\n *\n * @example\n * ```ts\n * const realtime = new InMemoryRealtimeAdapter();\n *\n * const subId = await realtime.subscribe('records', (event) => {\n * console.log('Received:', event.type, event.payload);\n * }, { object: 'account', eventTypes: ['record.created'] });\n *\n * await realtime.publish({\n * type: 'record.created',\n * object: 'account',\n * payload: { id: 'acc-1', name: 'Acme' },\n * timestamp: new Date().toISOString(),\n * });\n *\n * await realtime.unsubscribe(subId);\n * ```\n */\nexport class InMemoryRealtimeAdapter implements IRealtimeService {\n private readonly subscriptions = new Map<string, Subscription>();\n private readonly channelIndex = new Map<string, Set<string>>();\n private counter = 0;\n private readonly maxSubscriptions: number;\n\n constructor(options: InMemoryRealtimeAdapterOptions = {}) {\n this.maxSubscriptions = options.maxSubscriptions ?? 0;\n }\n\n async publish(event: RealtimeEventPayload): Promise<void> {\n // Deliver to all channel subscriptions that match filters\n for (const sub of this.subscriptions.values()) {\n if (this.matchesSubscription(event, sub)) {\n try {\n await sub.handler(event);\n } catch {\n // Swallow handler errors to avoid breaking the publish loop\n }\n }\n }\n }\n\n async subscribe(\n channel: string,\n handler: RealtimeEventHandler,\n options?: RealtimeSubscriptionOptions,\n ): Promise<string> {\n if (this.maxSubscriptions > 0 && this.subscriptions.size >= this.maxSubscriptions) {\n throw new Error(\n `Maximum subscription limit reached (${this.maxSubscriptions}). ` +\n 'Unsubscribe from existing channels before adding new subscriptions.',\n );\n }\n\n const id = `sub-${++this.counter}`;\n const sub: Subscription = { id, channel, handler, options };\n this.subscriptions.set(id, sub);\n\n // Maintain channel index for efficient lookups\n if (!this.channelIndex.has(channel)) {\n this.channelIndex.set(channel, new Set());\n }\n this.channelIndex.get(channel)!.add(id);\n\n return id;\n }\n\n async unsubscribe(subscriptionId: string): Promise<void> {\n const sub = this.subscriptions.get(subscriptionId);\n if (!sub) return;\n\n this.subscriptions.delete(subscriptionId);\n\n // Clean up channel index\n const channelSubs = this.channelIndex.get(sub.channel);\n if (channelSubs) {\n channelSubs.delete(subscriptionId);\n if (channelSubs.size === 0) {\n this.channelIndex.delete(sub.channel);\n }\n }\n }\n\n /**\n * Get the number of active subscriptions.\n */\n getSubscriptionCount(): number {\n return this.subscriptions.size;\n }\n\n /**\n * Get all active channel names.\n */\n getChannels(): string[] {\n return Array.from(this.channelIndex.keys());\n }\n\n /**\n * Check if an event matches a subscription's filters.\n */\n private matchesSubscription(event: RealtimeEventPayload, sub: Subscription): boolean {\n const opts = sub.options;\n if (!opts) return true;\n\n // Filter by object name\n if (opts.object && event.object !== opts.object) {\n return false;\n }\n\n // Filter by event types\n if (opts.eventTypes && opts.eventTypes.length > 0) {\n if (!opts.eventTypes.includes(event.type)) {\n return false;\n }\n }\n\n return true;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { ObjectSchema, Field } from '@objectstack/spec/data';\n\n/**\n * sys_presence — System Presence Object\n *\n * Tracks real-time user presence and activity across the platform.\n * Fields align with the PresenceStateSchema protocol definition\n * from `@objectstack/spec/api` (websocket.zod.ts).\n *\n * Owned by `service-realtime` as the canonical Presence domain object.\n *\n * @namespace sys\n * @see PresenceStateSchema in packages/spec/src/api/websocket.zod.ts\n */\nexport const SysPresence = ObjectSchema.create({\n namespace: 'sys',\n name: 'presence',\n label: 'Presence',\n pluralLabel: 'Presences',\n icon: 'wifi',\n isSystem: true,\n description: 'Real-time user presence and activity tracking',\n titleFormat: '{user_id} ({status})',\n compactLayout: ['user_id', 'status', 'last_seen'],\n\n fields: {\n id: Field.text({\n label: 'Presence ID',\n required: true,\n readonly: true,\n }),\n\n created_at: Field.datetime({\n label: 'Created At',\n defaultValue: 'NOW()',\n readonly: true,\n }),\n\n updated_at: Field.datetime({\n label: 'Updated At',\n defaultValue: 'NOW()',\n readonly: true,\n }),\n\n user_id: Field.text({\n label: 'User ID',\n required: true,\n searchable: true,\n }),\n\n session_id: Field.text({\n label: 'Session ID',\n required: true,\n }),\n\n status: Field.select({\n label: 'Status',\n required: true,\n defaultValue: 'online',\n options: [\n { value: 'online', label: 'Online' },\n { value: 'away', label: 'Away' },\n { value: 'busy', label: 'Busy' },\n { value: 'offline', label: 'Offline' },\n ],\n }),\n\n last_seen: Field.datetime({\n label: 'Last Seen',\n required: true,\n defaultValue: 'NOW()',\n }),\n\n current_location: Field.text({\n label: 'Current Location',\n required: false,\n maxLength: 500,\n }),\n\n device: Field.select({\n label: 'Device',\n required: false,\n options: [\n { value: 'desktop', label: 'Desktop' },\n { value: 'mobile', label: 'Mobile' },\n { value: 'tablet', label: 'Tablet' },\n { value: 'other', label: 'Other' },\n ],\n }),\n\n custom_status: Field.text({\n label: 'Custom Status',\n required: false,\n maxLength: 255,\n }),\n\n metadata: Field.json({\n label: 'Metadata',\n required: false,\n description: 'Arbitrary JSON metadata associated with the presence state (matches PresenceStateSchema.metadata).',\n }),\n },\n\n indexes: [\n { fields: ['user_id'], unique: false },\n { fields: ['session_id'], unique: true },\n { fields: ['status'], unique: false },\n ],\n\n enable: {\n trackHistory: false,\n searchable: false,\n apiEnabled: true,\n apiMethods: ['get', 'list', 'create', 'update', 'delete'],\n trash: false,\n mru: false,\n },\n});\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';\nimport type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';\nimport { SysPresence } from './objects/index.js';\n\n/**\n * Configuration options for the RealtimeServicePlugin.\n */\nexport interface RealtimeServicePluginOptions {\n /** Realtime adapter type (default: 'memory') */\n adapter?: 'memory';\n /** Options for the in-memory adapter */\n memory?: InMemoryRealtimeAdapterOptions;\n}\n\n/**\n * RealtimeServicePlugin — Production IRealtimeService implementation.\n *\n * Registers a realtime pub/sub service with the kernel during the init phase.\n * Currently supports in-memory pub/sub for single-process environments.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { RealtimeServicePlugin } from '@objectstack/service-realtime';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new RealtimeServicePlugin());\n * await kernel.bootstrap();\n *\n * const realtime = kernel.getService('realtime');\n * await realtime.subscribe('records', (event) => {\n * console.log(event.type, event.payload);\n * });\n * ```\n */\nexport class RealtimeServicePlugin implements Plugin {\n name = 'com.objectstack.service.realtime';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: RealtimeServicePluginOptions;\n\n constructor(options: RealtimeServicePluginOptions = {}) {\n this.options = { adapter: 'memory', ...options };\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const realtime = new InMemoryRealtimeAdapter(this.options.memory);\n ctx.registerService('realtime', realtime);\n\n // Register realtime system objects via the manifest service.\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.realtime',\n name: 'Realtime Service',\n version: '1.0.0',\n type: 'plugin',\n namespace: 'sys',\n objects: [SysPresence],\n });\n\n ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter');\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/realtime-service-plugin.ts","../src/in-memory-realtime-adapter.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { RealtimeServicePlugin } from './realtime-service-plugin.js';\nexport type { RealtimeServicePluginOptions } from './realtime-service-plugin.js';\nexport { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';\nexport type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { SysPresence } from '@objectstack/platform-objects/audit';\nimport { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';\nimport type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';\n\n/**\n * Configuration options for the RealtimeServicePlugin.\n */\nexport interface RealtimeServicePluginOptions {\n /** Realtime adapter type (default: 'memory') */\n adapter?: 'memory';\n /** Options for the in-memory adapter */\n memory?: InMemoryRealtimeAdapterOptions;\n}\n\n/**\n * RealtimeServicePlugin — Production IRealtimeService implementation.\n *\n * Registers a realtime pub/sub service with the kernel during the init phase.\n * Currently supports in-memory pub/sub for single-process environments.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { RealtimeServicePlugin } from '@objectstack/service-realtime';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new RealtimeServicePlugin());\n * await kernel.bootstrap();\n *\n * const realtime = kernel.getService('realtime');\n * await realtime.subscribe('records', (event) => {\n * console.log(event.type, event.payload);\n * });\n * ```\n */\nexport class RealtimeServicePlugin implements Plugin {\n name = 'com.objectstack.service.realtime';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: RealtimeServicePluginOptions;\n\n constructor(options: RealtimeServicePluginOptions = {}) {\n this.options = { adapter: 'memory', ...options };\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const realtime = new InMemoryRealtimeAdapter(this.options.memory);\n ctx.registerService('realtime', realtime);\n\n // Register realtime system objects via the manifest service.\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.realtime',\n name: 'Realtime Service',\n version: '1.0.0',\n type: 'plugin',\n namespace: 'sys',\n objects: [SysPresence],\n });\n\n ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter');\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IRealtimeService,\n RealtimeEventPayload,\n RealtimeEventHandler,\n RealtimeSubscriptionOptions,\n} from '@objectstack/spec/contracts';\n\n/**\n * Internal subscription entry.\n */\ninterface Subscription {\n id: string;\n channel: string;\n handler: RealtimeEventHandler;\n options?: RealtimeSubscriptionOptions;\n}\n\n/**\n * Configuration options for InMemoryRealtimeAdapter.\n */\nexport interface InMemoryRealtimeAdapterOptions {\n /** Maximum number of subscriptions allowed (0 = unlimited) */\n maxSubscriptions?: number;\n}\n\n/**\n * In-memory pub/sub adapter implementing IRealtimeService.\n *\n * Uses a Map-backed subscription store with channel-based routing.\n * Supports event type and object filtering via subscription options.\n *\n * Suitable for single-process environments, development, and testing.\n * For production multi-instance deployments, use a Redis-backed adapter.\n *\n * @example\n * ```ts\n * const realtime = new InMemoryRealtimeAdapter();\n *\n * const subId = await realtime.subscribe('records', (event) => {\n * console.log('Received:', event.type, event.payload);\n * }, { object: 'account', eventTypes: ['record.created'] });\n *\n * await realtime.publish({\n * type: 'record.created',\n * object: 'account',\n * payload: { id: 'acc-1', name: 'Acme' },\n * timestamp: new Date().toISOString(),\n * });\n *\n * await realtime.unsubscribe(subId);\n * ```\n */\nexport class InMemoryRealtimeAdapter implements IRealtimeService {\n private readonly subscriptions = new Map<string, Subscription>();\n private readonly channelIndex = new Map<string, Set<string>>();\n private counter = 0;\n private readonly maxSubscriptions: number;\n\n constructor(options: InMemoryRealtimeAdapterOptions = {}) {\n this.maxSubscriptions = options.maxSubscriptions ?? 0;\n }\n\n async publish(event: RealtimeEventPayload): Promise<void> {\n // Deliver to all channel subscriptions that match filters\n for (const sub of this.subscriptions.values()) {\n if (this.matchesSubscription(event, sub)) {\n try {\n await sub.handler(event);\n } catch {\n // Swallow handler errors to avoid breaking the publish loop\n }\n }\n }\n }\n\n async subscribe(\n channel: string,\n handler: RealtimeEventHandler,\n options?: RealtimeSubscriptionOptions,\n ): Promise<string> {\n if (this.maxSubscriptions > 0 && this.subscriptions.size >= this.maxSubscriptions) {\n throw new Error(\n `Maximum subscription limit reached (${this.maxSubscriptions}). ` +\n 'Unsubscribe from existing channels before adding new subscriptions.',\n );\n }\n\n const id = `sub-${++this.counter}`;\n const sub: Subscription = { id, channel, handler, options };\n this.subscriptions.set(id, sub);\n\n // Maintain channel index for efficient lookups\n if (!this.channelIndex.has(channel)) {\n this.channelIndex.set(channel, new Set());\n }\n this.channelIndex.get(channel)!.add(id);\n\n return id;\n }\n\n async unsubscribe(subscriptionId: string): Promise<void> {\n const sub = this.subscriptions.get(subscriptionId);\n if (!sub) return;\n\n this.subscriptions.delete(subscriptionId);\n\n // Clean up channel index\n const channelSubs = this.channelIndex.get(sub.channel);\n if (channelSubs) {\n channelSubs.delete(subscriptionId);\n if (channelSubs.size === 0) {\n this.channelIndex.delete(sub.channel);\n }\n }\n }\n\n /**\n * Get the number of active subscriptions.\n */\n getSubscriptionCount(): number {\n return this.subscriptions.size;\n }\n\n /**\n * Get all active channel names.\n */\n getChannels(): string[] {\n return Array.from(this.channelIndex.keys());\n }\n\n /**\n * Check if an event matches a subscription's filters.\n */\n private matchesSubscription(event: RealtimeEventPayload, sub: Subscription): boolean {\n const opts = sub.options;\n if (!opts) return true;\n\n // Filter by object name\n if (opts.object && event.object !== opts.object) {\n return false;\n }\n\n // Filter by event types\n if (opts.eventTypes && opts.eventTypes.length > 0) {\n if (!opts.eventTypes.includes(event.type)) {\n return false;\n }\n }\n\n return true;\n }\n}\n"],"mappings":"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","names":[]}
|