@modern-admin/realtime 0.1.0
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/dist/gateway.d.ts +50 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +98 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +18 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +36 -0
- package/dist/module.js.map +1 -0
- package/dist/redis-bus.d.ts +41 -0
- package/dist/redis-bus.d.ts.map +1 -0
- package/dist/redis-bus.js +75 -0
- package/dist/redis-bus.js.map +1 -0
- package/dist/tokens.d.ts +5 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +5 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +63 -0
- package/src/gateway.ts +101 -0
- package/src/index.ts +9 -0
- package/src/module.ts +33 -0
- package/src/redis-bus.ts +100 -0
- package/src/tokens.ts +5 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { type OnGatewayInit } from '@nestjs/websockets';
|
|
3
|
+
import type { IRealtimeBus, RealtimeEvent } from '@modern-admin/core';
|
|
4
|
+
/**
|
|
5
|
+
* Minimal duck-typed surface of a socket.io-style server we rely on. Kept
|
|
6
|
+
* structural so we don't import socket.io directly and so consumers that
|
|
7
|
+
* use a different platform adapter (ws, uWebSockets) can still wire this
|
|
8
|
+
* gateway as long as `emit` and `to(room).emit` exist.
|
|
9
|
+
*/
|
|
10
|
+
interface RealtimeServerLike {
|
|
11
|
+
emit(event: string, ...args: unknown[]): unknown;
|
|
12
|
+
to?(room: string): {
|
|
13
|
+
emit(event: string, ...args: unknown[]): unknown;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
interface RealtimeSocketLike {
|
|
17
|
+
id: string;
|
|
18
|
+
join?(room: string): unknown;
|
|
19
|
+
leave?(room: string): unknown;
|
|
20
|
+
emit(event: string, ...args: unknown[]): unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* NestJS WebSocket gateway. On bootstrap subscribes to the realtime bus and
|
|
24
|
+
* fans events out to socket.io clients. Clients can opt into per-resource
|
|
25
|
+
* rooms via the `subscribe` / `unsubscribe` messages to reduce noise.
|
|
26
|
+
*/
|
|
27
|
+
export declare class RealtimeGateway implements OnGatewayInit, OnModuleInit {
|
|
28
|
+
private readonly bus;
|
|
29
|
+
server: RealtimeServerLike;
|
|
30
|
+
private unsubscribe;
|
|
31
|
+
constructor(bus: IRealtimeBus);
|
|
32
|
+
onModuleInit(): Promise<void>;
|
|
33
|
+
afterInit(): void;
|
|
34
|
+
/** Broadcast an event to subscribers of the resource room and the global room. */
|
|
35
|
+
broadcast(event: RealtimeEvent): void;
|
|
36
|
+
handleSubscribe(body: {
|
|
37
|
+
resourceId?: string;
|
|
38
|
+
all?: boolean;
|
|
39
|
+
}, client: RealtimeSocketLike): {
|
|
40
|
+
ok: true;
|
|
41
|
+
};
|
|
42
|
+
handleUnsubscribe(body: {
|
|
43
|
+
resourceId?: string;
|
|
44
|
+
all?: boolean;
|
|
45
|
+
}, client: RealtimeSocketLike): {
|
|
46
|
+
ok: true;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=gateway.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAC1D,OAAO,EAGL,KAAK,aAAa,EAInB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;GAKG;AACH,UAAU,kBAAkB;IAC1B,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;IAChD,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;KAAE,CAAA;CACxE;AAED,UAAU,kBAAkB;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;IAC5B,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;IAC7B,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;CACjD;AAKD;;;;GAIG;AACH,qBAIa,eAAgB,YAAW,aAAa,EAAE,YAAY;IAM/B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAJ/C,MAAM,EAAG,kBAAkB,CAAA;IAElC,OAAO,CAAC,WAAW,CAA4B;gBAEI,GAAG,EAAE,YAAY;IAE9D,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAOnC,SAAS,IAAI,IAAI;IAIjB,kFAAkF;IAClF,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAYrC,eAAe,CACE,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,OAAO,CAAA;KAAE,EACxC,MAAM,EAAE,kBAAkB,GAC5C;QAAE,EAAE,EAAE,IAAI,CAAA;KAAE;IAWf,iBAAiB,CACA,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,OAAO,CAAA;KAAE,EACxC,MAAM,EAAE,kBAAkB,GAC5C;QAAE,EAAE,EAAE,IAAI,CAAA;KAAE;CAShB"}
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject } from '@nestjs/common';
|
|
14
|
+
import { ConnectedSocket, MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets';
|
|
15
|
+
import { REALTIME_BUS, REALTIME_EVENT } from './tokens.js';
|
|
16
|
+
const ALL_ROOM = 'modern-admin:all';
|
|
17
|
+
const resourceRoom = (resourceId) => `modern-admin:resource:${resourceId}`;
|
|
18
|
+
/**
|
|
19
|
+
* NestJS WebSocket gateway. On bootstrap subscribes to the realtime bus and
|
|
20
|
+
* fans events out to socket.io clients. Clients can opt into per-resource
|
|
21
|
+
* rooms via the `subscribe` / `unsubscribe` messages to reduce noise.
|
|
22
|
+
*/
|
|
23
|
+
let RealtimeGateway = class RealtimeGateway {
|
|
24
|
+
constructor(bus) {
|
|
25
|
+
this.bus = bus;
|
|
26
|
+
this.unsubscribe = null;
|
|
27
|
+
}
|
|
28
|
+
async onModuleInit() {
|
|
29
|
+
if (this.unsubscribe)
|
|
30
|
+
return;
|
|
31
|
+
this.unsubscribe = await this.bus.subscribe((event) => {
|
|
32
|
+
this.broadcast(event);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
afterInit() {
|
|
36
|
+
// Hook reserved for socket.io middleware injection by consumers.
|
|
37
|
+
}
|
|
38
|
+
/** Broadcast an event to subscribers of the resource room and the global room. */
|
|
39
|
+
broadcast(event) {
|
|
40
|
+
const server = this.server;
|
|
41
|
+
if (!server)
|
|
42
|
+
return;
|
|
43
|
+
if (typeof server.to === 'function') {
|
|
44
|
+
server.to(resourceRoom(event.resourceId)).emit(REALTIME_EVENT, event);
|
|
45
|
+
server.to(ALL_ROOM).emit(REALTIME_EVENT, event);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
server.emit(REALTIME_EVENT, event);
|
|
49
|
+
}
|
|
50
|
+
handleSubscribe(body, client) {
|
|
51
|
+
if (body?.all && typeof client.join === 'function') {
|
|
52
|
+
client.join(ALL_ROOM);
|
|
53
|
+
}
|
|
54
|
+
if (body?.resourceId && typeof client.join === 'function') {
|
|
55
|
+
client.join(resourceRoom(body.resourceId));
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
handleUnsubscribe(body, client) {
|
|
60
|
+
if (body?.all && typeof client.leave === 'function') {
|
|
61
|
+
client.leave(ALL_ROOM);
|
|
62
|
+
}
|
|
63
|
+
if (body?.resourceId && typeof client.leave === 'function') {
|
|
64
|
+
client.leave(resourceRoom(body.resourceId));
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
__decorate([
|
|
70
|
+
WebSocketServer(),
|
|
71
|
+
__metadata("design:type", Object)
|
|
72
|
+
], RealtimeGateway.prototype, "server", void 0);
|
|
73
|
+
__decorate([
|
|
74
|
+
SubscribeMessage('subscribe'),
|
|
75
|
+
__param(0, MessageBody()),
|
|
76
|
+
__param(1, ConnectedSocket()),
|
|
77
|
+
__metadata("design:type", Function),
|
|
78
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
79
|
+
__metadata("design:returntype", Object)
|
|
80
|
+
], RealtimeGateway.prototype, "handleSubscribe", null);
|
|
81
|
+
__decorate([
|
|
82
|
+
SubscribeMessage('unsubscribe'),
|
|
83
|
+
__param(0, MessageBody()),
|
|
84
|
+
__param(1, ConnectedSocket()),
|
|
85
|
+
__metadata("design:type", Function),
|
|
86
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
87
|
+
__metadata("design:returntype", Object)
|
|
88
|
+
], RealtimeGateway.prototype, "handleUnsubscribe", null);
|
|
89
|
+
RealtimeGateway = __decorate([
|
|
90
|
+
WebSocketGateway({
|
|
91
|
+
namespace: 'admin/realtime',
|
|
92
|
+
cors: { origin: true, credentials: true },
|
|
93
|
+
}),
|
|
94
|
+
__param(0, Inject(REALTIME_BUS)),
|
|
95
|
+
__metadata("design:paramtypes", [Object])
|
|
96
|
+
], RealtimeGateway);
|
|
97
|
+
export { RealtimeGateway };
|
|
98
|
+
//# sourceMappingURL=gateway.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.js","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ModernAdminRealtimeModule, type ModernAdminRealtimeModuleOptions } from './module.js';
|
|
2
|
+
export { RealtimeGateway } from './gateway.js';
|
|
3
|
+
export { RedisRealtimeBus, type RealtimeRedisLike, type RedisRealtimeBusOptions } from './redis-bus.js';
|
|
4
|
+
export { REALTIME_BUS, REALTIME_EVENT } from './tokens.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,yBAAyB,EAAE,KAAK,gCAAgC,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,KAAK,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,gBAAgB,CAAA;AACvG,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @modern-admin/realtime — WebSocket gateway and pub/sub buses for live
|
|
2
|
+
// resource events. Pair `ModernAdminRealtimeModule.forRoot({ bus })` with
|
|
3
|
+
// `ModernAdminModule.forRoot({ realtime: bus })` to broadcast every
|
|
4
|
+
// create/update/delete to connected clients.
|
|
5
|
+
export { ModernAdminRealtimeModule } from './module.js';
|
|
6
|
+
export { RealtimeGateway } from './gateway.js';
|
|
7
|
+
export { RedisRealtimeBus } from './redis-bus.js';
|
|
8
|
+
export { REALTIME_BUS, REALTIME_EVENT } from './tokens.js';
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,0EAA0E;AAC1E,oEAAoE;AACpE,6CAA6C;AAE7C,OAAO,EAAE,yBAAyB,EAAyC,MAAM,aAAa,CAAA;AAC9F,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAwD,MAAM,gBAAgB,CAAA;AACvG,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
import { type IRealtimeBus } from '@modern-admin/core';
|
|
3
|
+
export interface ModernAdminRealtimeModuleOptions {
|
|
4
|
+
/** Custom bus implementation (e.g. RedisRealtimeBus). Defaults to in-memory. */
|
|
5
|
+
bus?: IRealtimeBus;
|
|
6
|
+
/** Make the module global so other features can reuse the bus token. */
|
|
7
|
+
global?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* NestJS dynamic module wrapping the realtime bus and the WebSocket gateway.
|
|
11
|
+
*
|
|
12
|
+
* Pair with `ModernAdminModule.forRoot({ realtime: <same bus> })` so the
|
|
13
|
+
* core invoke pipeline publishes onto the same bus the gateway listens to.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ModernAdminRealtimeModule {
|
|
16
|
+
static forRoot(options?: ModernAdminRealtimeModuleOptions): DynamicModule;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAU,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAI3E,MAAM,WAAW,gCAAgC;IAC/C,gFAAgF;IAChF,GAAG,CAAC,EAAE,YAAY,CAAA;IAClB,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED;;;;;GAKG;AACH,qBACa,yBAAyB;IACpC,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,gCAAqC,GAAG,aAAa;CAY9E"}
|
package/dist/module.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var ModernAdminRealtimeModule_1;
|
|
8
|
+
import { Module } from '@nestjs/common';
|
|
9
|
+
import { InMemoryRealtimeBus } from '@modern-admin/core';
|
|
10
|
+
import { RealtimeGateway } from './gateway.js';
|
|
11
|
+
import { REALTIME_BUS } from './tokens.js';
|
|
12
|
+
/**
|
|
13
|
+
* NestJS dynamic module wrapping the realtime bus and the WebSocket gateway.
|
|
14
|
+
*
|
|
15
|
+
* Pair with `ModernAdminModule.forRoot({ realtime: <same bus> })` so the
|
|
16
|
+
* core invoke pipeline publishes onto the same bus the gateway listens to.
|
|
17
|
+
*/
|
|
18
|
+
let ModernAdminRealtimeModule = ModernAdminRealtimeModule_1 = class ModernAdminRealtimeModule {
|
|
19
|
+
static forRoot(options = {}) {
|
|
20
|
+
const bus = options.bus ?? new InMemoryRealtimeBus();
|
|
21
|
+
return {
|
|
22
|
+
module: ModernAdminRealtimeModule_1,
|
|
23
|
+
global: options.global ?? false,
|
|
24
|
+
providers: [
|
|
25
|
+
{ provide: REALTIME_BUS, useValue: bus },
|
|
26
|
+
RealtimeGateway,
|
|
27
|
+
],
|
|
28
|
+
exports: [REALTIME_BUS, RealtimeGateway],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
ModernAdminRealtimeModule = ModernAdminRealtimeModule_1 = __decorate([
|
|
33
|
+
Module({})
|
|
34
|
+
], ModernAdminRealtimeModule);
|
|
35
|
+
export { ModernAdminRealtimeModule };
|
|
36
|
+
//# sourceMappingURL=module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.js","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAsB,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,mBAAmB,EAAqB,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAS1C;;;;;GAKG;AAEI,IAAM,yBAAyB,iCAA/B,MAAM,yBAAyB;IACpC,MAAM,CAAC,OAAO,CAAC,UAA4C,EAAE;QAC3D,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,mBAAmB,EAAE,CAAA;QACpD,OAAO;YACL,MAAM,EAAE,2BAAyB;YACjC,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,KAAK;YAC/B,SAAS,EAAE;gBACT,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,GAAG,EAAE;gBACxC,eAAe;aAChB;YACD,OAAO,EAAE,CAAC,YAAY,EAAE,eAAe,CAAC;SACzC,CAAA;IACH,CAAC;CACF,CAAA;AAbY,yBAAyB;IADrC,MAAM,CAAC,EAAE,CAAC;GACE,yBAAyB,CAarC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IRealtimeBus, RealtimeEvent, RealtimeHandler } from '@modern-admin/core';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal duck-typed surface of an ioredis client. We never import the real
|
|
4
|
+
* type so consumers can swap implementations (mock, sentinel, cluster) as
|
|
5
|
+
* long as `publish`, `subscribe`, and `duplicate` are present.
|
|
6
|
+
*/
|
|
7
|
+
export interface RealtimeRedisLike {
|
|
8
|
+
publish(channel: string, message: string): Promise<unknown>;
|
|
9
|
+
subscribe(channel: string): Promise<unknown> | unknown;
|
|
10
|
+
unsubscribe(channel: string): Promise<unknown> | unknown;
|
|
11
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
12
|
+
off(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
13
|
+
duplicate(): RealtimeRedisLike;
|
|
14
|
+
/** Optional teardown for tests/process exits. */
|
|
15
|
+
quit?(): Promise<unknown> | unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface RedisRealtimeBusOptions {
|
|
18
|
+
client: RealtimeRedisLike;
|
|
19
|
+
channel?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Realtime bus backed by Redis pub/sub for cross-instance fan-out. Uses a
|
|
23
|
+
* dedicated subscriber connection (`client.duplicate()`) so subscriptions do
|
|
24
|
+
* not block the publisher's command pipeline. Events are JSON-encoded; an
|
|
25
|
+
* unparseable payload is dropped silently to avoid bringing the listener
|
|
26
|
+
* down on a single bad publish.
|
|
27
|
+
*/
|
|
28
|
+
export declare class RedisRealtimeBus implements IRealtimeBus {
|
|
29
|
+
private readonly publisher;
|
|
30
|
+
private readonly channel;
|
|
31
|
+
private subscriber;
|
|
32
|
+
private subscriberStarted;
|
|
33
|
+
private readonly handlers;
|
|
34
|
+
constructor(options: RedisRealtimeBusOptions);
|
|
35
|
+
publish(event: RealtimeEvent): Promise<void>;
|
|
36
|
+
subscribe(handler: RealtimeHandler): Promise<() => void>;
|
|
37
|
+
/** Stop the subscriber connection. Useful in tests / graceful shutdown. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
private ensureSubscriber;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=redis-bus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-bus.d.ts","sourceRoot":"","sources":["../src/redis-bus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA;AAEtF;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAC3D,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;IACtD,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;IACxD,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAA;IACpF,SAAS,IAAI,iBAAiB,CAAA;IAC9B,iDAAiD;IACjD,IAAI,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;CACpC;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,iBAAiB,CAAA;IACzB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAID;;;;;;GAMG;AACH,qBAAa,gBAAiB,YAAW,YAAY;IACnD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,UAAU,CAAiC;IACnD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;gBAE1C,OAAO,EAAE,uBAAuB;IAKtC,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5C,SAAS,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC;IAQ9D,2EAA2E;IACrE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAqBd,gBAAgB;CAqB/B"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const DEFAULT_CHANNEL = 'modern-admin:realtime';
|
|
2
|
+
/**
|
|
3
|
+
* Realtime bus backed by Redis pub/sub for cross-instance fan-out. Uses a
|
|
4
|
+
* dedicated subscriber connection (`client.duplicate()`) so subscriptions do
|
|
5
|
+
* not block the publisher's command pipeline. Events are JSON-encoded; an
|
|
6
|
+
* unparseable payload is dropped silently to avoid bringing the listener
|
|
7
|
+
* down on a single bad publish.
|
|
8
|
+
*/
|
|
9
|
+
export class RedisRealtimeBus {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.subscriber = null;
|
|
12
|
+
this.subscriberStarted = null;
|
|
13
|
+
this.handlers = new Set();
|
|
14
|
+
this.publisher = options.client;
|
|
15
|
+
this.channel = options.channel ?? DEFAULT_CHANNEL;
|
|
16
|
+
}
|
|
17
|
+
async publish(event) {
|
|
18
|
+
await this.publisher.publish(this.channel, JSON.stringify(event));
|
|
19
|
+
}
|
|
20
|
+
async subscribe(handler) {
|
|
21
|
+
this.handlers.add(handler);
|
|
22
|
+
await this.ensureSubscriber();
|
|
23
|
+
return () => {
|
|
24
|
+
this.handlers.delete(handler);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Stop the subscriber connection. Useful in tests / graceful shutdown. */
|
|
28
|
+
async close() {
|
|
29
|
+
if (this.subscriber) {
|
|
30
|
+
const sub = this.subscriber;
|
|
31
|
+
this.subscriber = null;
|
|
32
|
+
this.subscriberStarted = null;
|
|
33
|
+
try {
|
|
34
|
+
await sub.unsubscribe(this.channel);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
if (typeof sub.quit === 'function') {
|
|
40
|
+
try {
|
|
41
|
+
await sub.quit();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.handlers.clear();
|
|
49
|
+
}
|
|
50
|
+
async ensureSubscriber() {
|
|
51
|
+
if (this.subscriberStarted)
|
|
52
|
+
return this.subscriberStarted;
|
|
53
|
+
const sub = this.publisher.duplicate();
|
|
54
|
+
this.subscriber = sub;
|
|
55
|
+
this.subscriberStarted = (async () => {
|
|
56
|
+
sub.on('message', (channel, message) => {
|
|
57
|
+
if (channel !== this.channel)
|
|
58
|
+
return;
|
|
59
|
+
let event;
|
|
60
|
+
try {
|
|
61
|
+
event = JSON.parse(message);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const handler of this.handlers) {
|
|
67
|
+
void handler(event);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
await sub.subscribe(this.channel);
|
|
71
|
+
})();
|
|
72
|
+
return this.subscriberStarted;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=redis-bus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-bus.js","sourceRoot":"","sources":["../src/redis-bus.ts"],"names":[],"mappings":"AAuBA,MAAM,eAAe,GAAG,uBAAuB,CAAA;AAE/C;;;;;;GAMG;AACH,MAAM,OAAO,gBAAgB;IAO3B,YAAY,OAAgC;QAJpC,eAAU,GAA6B,IAAI,CAAA;QAC3C,sBAAiB,GAAyB,IAAI,CAAA;QACrC,aAAQ,GAAG,IAAI,GAAG,EAAmB,CAAA;QAGpD,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,MAAM,CAAA;QAC/B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,CAAA;IACnD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,KAAoB;QAChC,MAAM,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;IACnE,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,OAAwB;QACtC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;QAC1B,MAAM,IAAI,CAAC,gBAAgB,EAAE,CAAA;QAC7B,OAAO,GAAG,EAAE;YACV,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAC/B,CAAC,CAAA;IACH,CAAC;IAED,2EAA2E;IAC3E,KAAK,CAAC,KAAK;QACT,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAA;YAC3B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAA;YACtB,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAA;YAC7B,IAAI,CAAC;gBACH,MAAM,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACrC,CAAC;YAAC,MAAM,CAAC;gBACP,SAAS;YACX,CAAC;YACD,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;gBACnC,IAAI,CAAC;oBACH,MAAM,GAAG,CAAC,IAAI,EAAE,CAAA;gBAClB,CAAC;gBAAC,MAAM,CAAC;oBACP,SAAS;gBACX,CAAC;YACH,CAAC;QACH,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAA;IACvB,CAAC;IAEO,KAAK,CAAC,gBAAgB;QAC5B,IAAI,IAAI,CAAC,iBAAiB;YAAE,OAAO,IAAI,CAAC,iBAAiB,CAAA;QACzD,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,CAAA;QACtC,IAAI,CAAC,UAAU,GAAG,GAAG,CAAA;QACrB,IAAI,CAAC,iBAAiB,GAAG,CAAC,KAAK,IAAI,EAAE;YACnC,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE;gBACrC,IAAI,OAAO,KAAK,IAAI,CAAC,OAAO;oBAAE,OAAM;gBACpC,IAAI,KAAoB,CAAA;gBACxB,IAAI,CAAC;oBACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAkB,CAAA;gBAC9C,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAM;gBACR,CAAC;gBACD,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;oBACpC,KAAK,OAAO,CAAC,KAAK,CAAC,CAAA;gBACrB,CAAC;YACH,CAAC,CAAC,CAAA;YACF,MAAM,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACnC,CAAC,CAAC,EAAE,CAAA;QACJ,OAAO,IAAI,CAAC,iBAAiB,CAAA;IAC/B,CAAC;CACF"}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** DI token for the IRealtimeBus instance shared across modules. */
|
|
2
|
+
export declare const REALTIME_BUS: unique symbol;
|
|
3
|
+
/** WebSocket event name used when broadcasting realtime events to clients. */
|
|
4
|
+
export declare const REALTIME_EVENT = "modernAdmin:realtime";
|
|
5
|
+
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,eAAO,MAAM,YAAY,eAA2C,CAAA;AAEpE,8EAA8E;AAC9E,eAAO,MAAM,cAAc,yBAAyB,CAAA"}
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** DI token for the IRealtimeBus instance shared across modules. */
|
|
2
|
+
export const REALTIME_BUS = Symbol.for('@modern-admin/realtime:Bus');
|
|
3
|
+
/** WebSocket event name used when broadcasting realtime events to clients. */
|
|
4
|
+
export const REALTIME_EVENT = 'modernAdmin:realtime';
|
|
5
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAA;AAEpE,8EAA8E;AAC9E,MAAM,CAAC,MAAM,cAAc,GAAG,sBAAsB,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modern-admin/realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WebSocket gateway + realtime bus implementations for Modern Admin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/modern-admin/modern-admin.git",
|
|
10
|
+
"directory": "packages/realtime"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org",
|
|
26
|
+
"access": "public",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modern-admin/core": "0.1.0",
|
|
38
|
+
"@modern-admin/nest": "0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@modern-admin/tsconfig": "0.1.0",
|
|
42
|
+
"@nestjs/common": "^11.1.19",
|
|
43
|
+
"@nestjs/websockets": "^11.1.19",
|
|
44
|
+
"@types/bun": "^1.3.13",
|
|
45
|
+
"typescript": "^6.0.3"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@nestjs/common": "^11.1.19",
|
|
49
|
+
"@nestjs/websockets": "^11.1.19",
|
|
50
|
+
"@nestjs/platform-socket.io": "^11.1.19"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@nestjs/common": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@nestjs/websockets": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"@nestjs/platform-socket.io": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Inject, type OnModuleInit } from '@nestjs/common'
|
|
2
|
+
import {
|
|
3
|
+
ConnectedSocket,
|
|
4
|
+
MessageBody,
|
|
5
|
+
type OnGatewayInit,
|
|
6
|
+
SubscribeMessage,
|
|
7
|
+
WebSocketGateway,
|
|
8
|
+
WebSocketServer,
|
|
9
|
+
} from '@nestjs/websockets'
|
|
10
|
+
import type { IRealtimeBus, RealtimeEvent } from '@modern-admin/core'
|
|
11
|
+
import { REALTIME_BUS, REALTIME_EVENT } from './tokens.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal duck-typed surface of a socket.io-style server we rely on. Kept
|
|
15
|
+
* structural so we don't import socket.io directly and so consumers that
|
|
16
|
+
* use a different platform adapter (ws, uWebSockets) can still wire this
|
|
17
|
+
* gateway as long as `emit` and `to(room).emit` exist.
|
|
18
|
+
*/
|
|
19
|
+
interface RealtimeServerLike {
|
|
20
|
+
emit(event: string, ...args: unknown[]): unknown
|
|
21
|
+
to?(room: string): { emit(event: string, ...args: unknown[]): unknown }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RealtimeSocketLike {
|
|
25
|
+
id: string
|
|
26
|
+
join?(room: string): unknown
|
|
27
|
+
leave?(room: string): unknown
|
|
28
|
+
emit(event: string, ...args: unknown[]): unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ALL_ROOM = 'modern-admin:all'
|
|
32
|
+
const resourceRoom = (resourceId: string): string => `modern-admin:resource:${resourceId}`
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* NestJS WebSocket gateway. On bootstrap subscribes to the realtime bus and
|
|
36
|
+
* fans events out to socket.io clients. Clients can opt into per-resource
|
|
37
|
+
* rooms via the `subscribe` / `unsubscribe` messages to reduce noise.
|
|
38
|
+
*/
|
|
39
|
+
@WebSocketGateway({
|
|
40
|
+
namespace: 'admin/realtime',
|
|
41
|
+
cors: { origin: true, credentials: true },
|
|
42
|
+
})
|
|
43
|
+
export class RealtimeGateway implements OnGatewayInit, OnModuleInit {
|
|
44
|
+
@WebSocketServer()
|
|
45
|
+
public server!: RealtimeServerLike
|
|
46
|
+
|
|
47
|
+
private unsubscribe: (() => void) | null = null
|
|
48
|
+
|
|
49
|
+
constructor(@Inject(REALTIME_BUS) private readonly bus: IRealtimeBus) {}
|
|
50
|
+
|
|
51
|
+
async onModuleInit(): Promise<void> {
|
|
52
|
+
if (this.unsubscribe) return
|
|
53
|
+
this.unsubscribe = await this.bus.subscribe((event) => {
|
|
54
|
+
this.broadcast(event)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterInit(): void {
|
|
59
|
+
// Hook reserved for socket.io middleware injection by consumers.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Broadcast an event to subscribers of the resource room and the global room. */
|
|
63
|
+
broadcast(event: RealtimeEvent): void {
|
|
64
|
+
const server = this.server
|
|
65
|
+
if (!server) return
|
|
66
|
+
if (typeof server.to === 'function') {
|
|
67
|
+
server.to(resourceRoom(event.resourceId)).emit(REALTIME_EVENT, event)
|
|
68
|
+
server.to(ALL_ROOM).emit(REALTIME_EVENT, event)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
server.emit(REALTIME_EVENT, event)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@SubscribeMessage('subscribe')
|
|
75
|
+
handleSubscribe(
|
|
76
|
+
@MessageBody() body: { resourceId?: string; all?: boolean },
|
|
77
|
+
@ConnectedSocket() client: RealtimeSocketLike,
|
|
78
|
+
): { ok: true } {
|
|
79
|
+
if (body?.all && typeof client.join === 'function') {
|
|
80
|
+
client.join(ALL_ROOM)
|
|
81
|
+
}
|
|
82
|
+
if (body?.resourceId && typeof client.join === 'function') {
|
|
83
|
+
client.join(resourceRoom(body.resourceId))
|
|
84
|
+
}
|
|
85
|
+
return { ok: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@SubscribeMessage('unsubscribe')
|
|
89
|
+
handleUnsubscribe(
|
|
90
|
+
@MessageBody() body: { resourceId?: string; all?: boolean },
|
|
91
|
+
@ConnectedSocket() client: RealtimeSocketLike,
|
|
92
|
+
): { ok: true } {
|
|
93
|
+
if (body?.all && typeof client.leave === 'function') {
|
|
94
|
+
client.leave(ALL_ROOM)
|
|
95
|
+
}
|
|
96
|
+
if (body?.resourceId && typeof client.leave === 'function') {
|
|
97
|
+
client.leave(resourceRoom(body.resourceId))
|
|
98
|
+
}
|
|
99
|
+
return { ok: true }
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @modern-admin/realtime — WebSocket gateway and pub/sub buses for live
|
|
2
|
+
// resource events. Pair `ModernAdminRealtimeModule.forRoot({ bus })` with
|
|
3
|
+
// `ModernAdminModule.forRoot({ realtime: bus })` to broadcast every
|
|
4
|
+
// create/update/delete to connected clients.
|
|
5
|
+
|
|
6
|
+
export { ModernAdminRealtimeModule, type ModernAdminRealtimeModuleOptions } from './module.js'
|
|
7
|
+
export { RealtimeGateway } from './gateway.js'
|
|
8
|
+
export { RedisRealtimeBus, type RealtimeRedisLike, type RedisRealtimeBusOptions } from './redis-bus.js'
|
|
9
|
+
export { REALTIME_BUS, REALTIME_EVENT } from './tokens.js'
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type DynamicModule, Module } from '@nestjs/common'
|
|
2
|
+
import { InMemoryRealtimeBus, type IRealtimeBus } from '@modern-admin/core'
|
|
3
|
+
import { RealtimeGateway } from './gateway.js'
|
|
4
|
+
import { REALTIME_BUS } from './tokens.js'
|
|
5
|
+
|
|
6
|
+
export interface ModernAdminRealtimeModuleOptions {
|
|
7
|
+
/** Custom bus implementation (e.g. RedisRealtimeBus). Defaults to in-memory. */
|
|
8
|
+
bus?: IRealtimeBus
|
|
9
|
+
/** Make the module global so other features can reuse the bus token. */
|
|
10
|
+
global?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* NestJS dynamic module wrapping the realtime bus and the WebSocket gateway.
|
|
15
|
+
*
|
|
16
|
+
* Pair with `ModernAdminModule.forRoot({ realtime: <same bus> })` so the
|
|
17
|
+
* core invoke pipeline publishes onto the same bus the gateway listens to.
|
|
18
|
+
*/
|
|
19
|
+
@Module({})
|
|
20
|
+
export class ModernAdminRealtimeModule {
|
|
21
|
+
static forRoot(options: ModernAdminRealtimeModuleOptions = {}): DynamicModule {
|
|
22
|
+
const bus = options.bus ?? new InMemoryRealtimeBus()
|
|
23
|
+
return {
|
|
24
|
+
module: ModernAdminRealtimeModule,
|
|
25
|
+
global: options.global ?? false,
|
|
26
|
+
providers: [
|
|
27
|
+
{ provide: REALTIME_BUS, useValue: bus },
|
|
28
|
+
RealtimeGateway,
|
|
29
|
+
],
|
|
30
|
+
exports: [REALTIME_BUS, RealtimeGateway],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/redis-bus.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { IRealtimeBus, RealtimeEvent, RealtimeHandler } from '@modern-admin/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal duck-typed surface of an ioredis client. We never import the real
|
|
5
|
+
* type so consumers can swap implementations (mock, sentinel, cluster) as
|
|
6
|
+
* long as `publish`, `subscribe`, and `duplicate` are present.
|
|
7
|
+
*/
|
|
8
|
+
export interface RealtimeRedisLike {
|
|
9
|
+
publish(channel: string, message: string): Promise<unknown>
|
|
10
|
+
subscribe(channel: string): Promise<unknown> | unknown
|
|
11
|
+
unsubscribe(channel: string): Promise<unknown> | unknown
|
|
12
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown
|
|
13
|
+
off(event: 'message', listener: (channel: string, message: string) => void): unknown
|
|
14
|
+
duplicate(): RealtimeRedisLike
|
|
15
|
+
/** Optional teardown for tests/process exits. */
|
|
16
|
+
quit?(): Promise<unknown> | unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RedisRealtimeBusOptions {
|
|
20
|
+
client: RealtimeRedisLike
|
|
21
|
+
channel?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CHANNEL = 'modern-admin:realtime'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Realtime bus backed by Redis pub/sub for cross-instance fan-out. Uses a
|
|
28
|
+
* dedicated subscriber connection (`client.duplicate()`) so subscriptions do
|
|
29
|
+
* not block the publisher's command pipeline. Events are JSON-encoded; an
|
|
30
|
+
* unparseable payload is dropped silently to avoid bringing the listener
|
|
31
|
+
* down on a single bad publish.
|
|
32
|
+
*/
|
|
33
|
+
export class RedisRealtimeBus implements IRealtimeBus {
|
|
34
|
+
private readonly publisher: RealtimeRedisLike
|
|
35
|
+
private readonly channel: string
|
|
36
|
+
private subscriber: RealtimeRedisLike | null = null
|
|
37
|
+
private subscriberStarted: Promise<void> | null = null
|
|
38
|
+
private readonly handlers = new Set<RealtimeHandler>()
|
|
39
|
+
|
|
40
|
+
constructor(options: RedisRealtimeBusOptions) {
|
|
41
|
+
this.publisher = options.client
|
|
42
|
+
this.channel = options.channel ?? DEFAULT_CHANNEL
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async publish(event: RealtimeEvent): Promise<void> {
|
|
46
|
+
await this.publisher.publish(this.channel, JSON.stringify(event))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async subscribe(handler: RealtimeHandler): Promise<() => void> {
|
|
50
|
+
this.handlers.add(handler)
|
|
51
|
+
await this.ensureSubscriber()
|
|
52
|
+
return () => {
|
|
53
|
+
this.handlers.delete(handler)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stop the subscriber connection. Useful in tests / graceful shutdown. */
|
|
58
|
+
async close(): Promise<void> {
|
|
59
|
+
if (this.subscriber) {
|
|
60
|
+
const sub = this.subscriber
|
|
61
|
+
this.subscriber = null
|
|
62
|
+
this.subscriberStarted = null
|
|
63
|
+
try {
|
|
64
|
+
await sub.unsubscribe(this.channel)
|
|
65
|
+
} catch {
|
|
66
|
+
// ignore
|
|
67
|
+
}
|
|
68
|
+
if (typeof sub.quit === 'function') {
|
|
69
|
+
try {
|
|
70
|
+
await sub.quit()
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.handlers.clear()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async ensureSubscriber(): Promise<void> {
|
|
80
|
+
if (this.subscriberStarted) return this.subscriberStarted
|
|
81
|
+
const sub = this.publisher.duplicate()
|
|
82
|
+
this.subscriber = sub
|
|
83
|
+
this.subscriberStarted = (async () => {
|
|
84
|
+
sub.on('message', (channel, message) => {
|
|
85
|
+
if (channel !== this.channel) return
|
|
86
|
+
let event: RealtimeEvent
|
|
87
|
+
try {
|
|
88
|
+
event = JSON.parse(message) as RealtimeEvent
|
|
89
|
+
} catch {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
for (const handler of this.handlers) {
|
|
93
|
+
void handler(event)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
await sub.subscribe(this.channel)
|
|
97
|
+
})()
|
|
98
|
+
return this.subscriberStarted
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** DI token for the IRealtimeBus instance shared across modules. */
|
|
2
|
+
export const REALTIME_BUS = Symbol.for('@modern-admin/realtime:Bus')
|
|
3
|
+
|
|
4
|
+
/** WebSocket event name used when broadcasting realtime events to clients. */
|
|
5
|
+
export const REALTIME_EVENT = 'modernAdmin:realtime'
|