@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.
@@ -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"}
@@ -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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"}
@@ -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"}
@@ -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"}
@@ -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
+ }
@@ -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'