@flusys/nestjs-shared 6.4.1 → 7.0.0-beta.1
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 +437 -0
- package/cjs/classes/api-service.class.js +71 -8
- package/cjs/classes/api-service.class.spec.js +220 -0
- package/cjs/classes/disabled-event-bus.class.js +15 -0
- package/cjs/classes/disabled-event-bus.class.spec.js +40 -0
- package/cjs/classes/domain-event.publisher.js +76 -0
- package/cjs/classes/domain-event.publisher.spec.js +234 -0
- package/cjs/classes/event-bus.registry.js +69 -0
- package/cjs/classes/event-bus.registry.spec.js +116 -0
- package/cjs/classes/event-transports/event-transport.registry.js +41 -0
- package/cjs/classes/event-transports/event-transport.registry.spec.js +31 -0
- package/cjs/classes/event-transports/index.js +23 -0
- package/cjs/classes/event-transports/kafka-transport.optional.js +151 -0
- package/cjs/classes/event-transports/kafka-transport.optional.spec.js +199 -0
- package/cjs/classes/event-transports/memory-transport.js +54 -0
- package/cjs/classes/event-transports/memory-transport.spec.js +58 -0
- package/cjs/classes/event-transports/rabbitmq-transport.optional.js +184 -0
- package/cjs/classes/event-transports/rabbitmq-transport.optional.spec.js +256 -0
- package/cjs/classes/hybrid-event-bus.class.js +261 -0
- package/cjs/classes/hybrid-event-bus.class.spec.js +189 -0
- package/cjs/classes/index.js +5 -0
- package/cjs/constants/index.js +24 -0
- package/cjs/decorators/index.js +1 -0
- package/cjs/decorators/on-domain-event.decorator.js +13 -0
- package/cjs/decorators/on-domain-event.decorator.spec.js +50 -0
- package/cjs/entities/user-root.js +2 -2
- package/cjs/enums/event-action.enum.js +18 -0
- package/cjs/enums/index.js +1 -0
- package/cjs/interfaces/event-bus.interface.js +4 -0
- package/cjs/interfaces/index.js +1 -0
- package/cjs/modules/event-bus/event-bus.explorer.js +124 -0
- package/cjs/modules/event-bus/event-bus.explorer.spec.js +189 -0
- package/cjs/modules/event-bus/event-bus.integration.spec.js +254 -0
- package/cjs/modules/event-bus/event-bus.module.js +94 -0
- package/cjs/modules/event-bus/event-bus.module.spec.js +149 -0
- package/cjs/modules/event-bus/module-events.provider.js +39 -0
- package/cjs/modules/event-bus/module-events.provider.spec.js +56 -0
- package/cjs/modules/index.js +3 -0
- package/cjs/utils/event-pattern.util.js +98 -0
- package/cjs/utils/event-pattern.util.spec.js +234 -0
- package/cjs/utils/index.js +2 -0
- package/cjs/utils/optional-module.util.js +35 -0
- package/cjs/utils/optional-module.util.spec.js +14 -0
- package/classes/api-service.class.d.ts +11 -1
- package/classes/disabled-event-bus.class.d.ts +6 -0
- package/classes/domain-event.publisher.d.ts +14 -0
- package/classes/event-bus.registry.d.ts +14 -0
- package/classes/event-transports/event-transport.registry.d.ts +10 -0
- package/classes/event-transports/index.d.ts +2 -0
- package/classes/event-transports/kafka-transport.optional.d.ts +21 -0
- package/classes/event-transports/memory-transport.d.ts +13 -0
- package/classes/event-transports/rabbitmq-transport.optional.d.ts +27 -0
- package/classes/hybrid-event-bus.class.d.ts +32 -0
- package/classes/index.d.ts +5 -0
- package/constants/index.d.ts +6 -0
- package/decorators/index.d.ts +1 -0
- package/decorators/on-domain-event.decorator.d.ts +1 -0
- package/enums/event-action.enum.d.ts +7 -0
- package/enums/index.d.ts +1 -0
- package/fesm/classes/api-service.class.js +71 -8
- package/fesm/classes/api-service.class.spec.js +220 -0
- package/fesm/classes/disabled-event-bus.class.js +11 -0
- package/fesm/classes/disabled-event-bus.class.spec.js +36 -0
- package/fesm/classes/domain-event.publisher.js +63 -0
- package/fesm/classes/domain-event.publisher.spec.js +230 -0
- package/fesm/classes/event-bus.registry.js +65 -0
- package/fesm/classes/event-bus.registry.spec.js +112 -0
- package/fesm/classes/event-transports/event-transport.registry.js +35 -0
- package/fesm/classes/event-transports/event-transport.registry.spec.js +27 -0
- package/fesm/classes/event-transports/index.js +6 -0
- package/fesm/classes/event-transports/kafka-transport.optional.js +144 -0
- package/fesm/classes/event-transports/kafka-transport.optional.spec.js +195 -0
- package/fesm/classes/event-transports/memory-transport.js +46 -0
- package/fesm/classes/event-transports/memory-transport.spec.js +54 -0
- package/fesm/classes/event-transports/rabbitmq-transport.optional.js +174 -0
- package/fesm/classes/event-transports/rabbitmq-transport.optional.spec.js +252 -0
- package/fesm/classes/hybrid-event-bus.class.js +217 -0
- package/fesm/classes/hybrid-event-bus.class.spec.js +185 -0
- package/fesm/classes/index.js +5 -0
- package/fesm/constants/index.js +7 -0
- package/fesm/decorators/index.js +1 -0
- package/fesm/decorators/on-domain-event.decorator.js +10 -0
- package/fesm/decorators/on-domain-event.decorator.spec.js +46 -0
- package/fesm/entities/user-root.js +1 -1
- package/fesm/enums/event-action.enum.js +8 -0
- package/fesm/enums/index.js +1 -0
- package/fesm/interfaces/event-bus.interface.js +1 -0
- package/fesm/interfaces/index.js +1 -0
- package/fesm/modules/event-bus/event-bus.explorer.js +114 -0
- package/fesm/modules/event-bus/event-bus.explorer.spec.js +185 -0
- package/fesm/modules/event-bus/event-bus.integration.spec.js +250 -0
- package/fesm/modules/event-bus/event-bus.module.js +84 -0
- package/fesm/modules/event-bus/event-bus.module.spec.js +145 -0
- package/fesm/modules/event-bus/module-events.provider.js +26 -0
- package/fesm/modules/event-bus/module-events.provider.spec.js +52 -0
- package/fesm/modules/index.js +3 -0
- package/fesm/utils/event-pattern.util.js +86 -0
- package/fesm/utils/event-pattern.util.spec.js +230 -0
- package/fesm/utils/index.js +2 -0
- package/fesm/utils/optional-module.util.js +25 -0
- package/fesm/utils/optional-module.util.spec.js +10 -0
- package/interfaces/event-bus.interface.d.ts +74 -0
- package/interfaces/index.d.ts +1 -0
- package/modules/event-bus/event-bus.explorer.d.ts +14 -0
- package/modules/event-bus/event-bus.module.d.ts +8 -0
- package/modules/event-bus/module-events.provider.d.ts +3 -0
- package/modules/index.d.ts +3 -0
- package/package.json +2 -2
- package/utils/event-pattern.util.d.ts +3 -0
- package/utils/index.d.ts +2 -0
- package/utils/optional-module.util.d.ts +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
function _ts_decorate(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
|
+
import { envConfig } from '@flusys/nestjs-core/config';
|
|
8
|
+
import { Logger, Module } from '@nestjs/common';
|
|
9
|
+
import { DiscoveryModule } from '@nestjs/core';
|
|
10
|
+
import { DisabledEventBus } from '../../classes/disabled-event-bus.class';
|
|
11
|
+
import { EventBusRegistry } from '../../classes/event-bus.registry';
|
|
12
|
+
import { HybridEventBus } from '../../classes/hybrid-event-bus.class';
|
|
13
|
+
import { EVENT_BUS_INSTANCE, EVENT_BUS_MODULE_OPTIONS } from '../../constants';
|
|
14
|
+
import { EventBusExplorer } from './event-bus.explorer';
|
|
15
|
+
export class EventBusModule {
|
|
16
|
+
static forRoot(options = {}) {
|
|
17
|
+
const enabled = options.enabled ?? envConfig.getBoolean('ENABLE_DOMAIN_EVENTS', false);
|
|
18
|
+
return enabled ? this.enabledModule(options) : this.disabledModule(options);
|
|
19
|
+
}
|
|
20
|
+
static enabledModule(options) {
|
|
21
|
+
return {
|
|
22
|
+
module: EventBusModule,
|
|
23
|
+
global: options.global ?? true,
|
|
24
|
+
imports: [
|
|
25
|
+
DiscoveryModule
|
|
26
|
+
],
|
|
27
|
+
providers: [
|
|
28
|
+
{
|
|
29
|
+
provide: EVENT_BUS_MODULE_OPTIONS,
|
|
30
|
+
useValue: options
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
provide: EVENT_BUS_INSTANCE,
|
|
34
|
+
useFactory: ()=>{
|
|
35
|
+
const bus = new HybridEventBus(options);
|
|
36
|
+
EventBusRegistry.setBus(bus);
|
|
37
|
+
EventBusRegistry.setDefaultModuleEvents(options.defaultModuleEvents ?? {});
|
|
38
|
+
return bus;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
EventBusExplorer
|
|
42
|
+
],
|
|
43
|
+
exports: [
|
|
44
|
+
EVENT_BUS_INSTANCE,
|
|
45
|
+
EVENT_BUS_MODULE_OPTIONS
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The explorer is left out, so no handler is bound and nothing ever calls
|
|
51
|
+
* `subscribe` - which is what would otherwise open a broker connection. The
|
|
52
|
+
* bus is not registered either, so every `publishDomainEvent` call is a no-op
|
|
53
|
+
* rather than an error.
|
|
54
|
+
*/ static disabledModule(options) {
|
|
55
|
+
return {
|
|
56
|
+
module: EventBusModule,
|
|
57
|
+
global: options.global ?? true,
|
|
58
|
+
providers: [
|
|
59
|
+
{
|
|
60
|
+
provide: EVENT_BUS_MODULE_OPTIONS,
|
|
61
|
+
useValue: options
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
provide: EVENT_BUS_INSTANCE,
|
|
65
|
+
useFactory: ()=>{
|
|
66
|
+
new Logger(EventBusModule.name).log('Domain events are disabled - set ENABLE_DOMAIN_EVENTS=true to publish and consume them');
|
|
67
|
+
return new DisabledEventBus();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
exports: [
|
|
72
|
+
EVENT_BUS_INSTANCE,
|
|
73
|
+
EVENT_BUS_MODULE_OPTIONS
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async onApplicationShutdown() {
|
|
78
|
+
await EventBusRegistry.getBus()?.close();
|
|
79
|
+
EventBusRegistry.reset();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
EventBusModule = _ts_decorate([
|
|
83
|
+
Module({})
|
|
84
|
+
], EventBusModule);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { DisabledEventBus } from '../../classes/disabled-event-bus.class';
|
|
2
|
+
import { EventBusRegistry } from '../../classes/event-bus.registry';
|
|
3
|
+
import { HybridEventBus } from '../../classes/hybrid-event-bus.class';
|
|
4
|
+
import { EVENT_BUS_INSTANCE, EVENT_BUS_MODULE_OPTIONS } from '../../constants';
|
|
5
|
+
import { EventBusExplorer } from './event-bus.explorer';
|
|
6
|
+
import { EventBusModule } from './event-bus.module';
|
|
7
|
+
function getBusProvider(providers) {
|
|
8
|
+
return providers.find((provider)=>provider.provide === EVENT_BUS_INSTANCE);
|
|
9
|
+
}
|
|
10
|
+
describe('EventBusModule', ()=>{
|
|
11
|
+
const originalFlag = process.env.ENABLE_DOMAIN_EVENTS;
|
|
12
|
+
afterEach(()=>{
|
|
13
|
+
EventBusRegistry.reset();
|
|
14
|
+
if (originalFlag === undefined) {
|
|
15
|
+
delete process.env.ENABLE_DOMAIN_EVENTS;
|
|
16
|
+
} else {
|
|
17
|
+
process.env.ENABLE_DOMAIN_EVENTS = originalFlag;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
describe('when enabled', ()=>{
|
|
21
|
+
it('should default to a global module', ()=>{
|
|
22
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
23
|
+
enabled: true
|
|
24
|
+
});
|
|
25
|
+
expect(dynamicModule.module).toBe(EventBusModule);
|
|
26
|
+
expect(dynamicModule.global).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('should respect global=false', ()=>{
|
|
29
|
+
expect(EventBusModule.forRoot({
|
|
30
|
+
enabled: true,
|
|
31
|
+
global: false
|
|
32
|
+
}).global).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('should export the bus instance and the module options', ()=>{
|
|
35
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
36
|
+
enabled: true
|
|
37
|
+
});
|
|
38
|
+
expect(dynamicModule.exports).toEqual([
|
|
39
|
+
EVENT_BUS_INSTANCE,
|
|
40
|
+
EVENT_BUS_MODULE_OPTIONS
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
it('should register the explorer so handlers get bound', ()=>{
|
|
44
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
45
|
+
enabled: true
|
|
46
|
+
});
|
|
47
|
+
expect(dynamicModule.providers).toContain(EventBusExplorer);
|
|
48
|
+
});
|
|
49
|
+
it('should build a HybridEventBus and register it for ApiService', ()=>{
|
|
50
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
51
|
+
enabled: true,
|
|
52
|
+
transport: 'memory'
|
|
53
|
+
});
|
|
54
|
+
const bus = getBusProvider(dynamicModule.providers).useFactory();
|
|
55
|
+
expect(bus).toBeInstanceOf(HybridEventBus);
|
|
56
|
+
expect(EventBusRegistry.getBus()).toBe(bus);
|
|
57
|
+
});
|
|
58
|
+
it('should apply the default module events config', ()=>{
|
|
59
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
60
|
+
enabled: true,
|
|
61
|
+
transport: 'memory',
|
|
62
|
+
defaultModuleEvents: {
|
|
63
|
+
enabled: true
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
getBusProvider(dynamicModule.providers).useFactory();
|
|
67
|
+
expect(EventBusRegistry.getModuleConfig('anything')).toEqual({
|
|
68
|
+
enabled: true
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it('should close the bus and clear the registry on shutdown', async ()=>{
|
|
72
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
73
|
+
enabled: true,
|
|
74
|
+
transport: 'memory'
|
|
75
|
+
});
|
|
76
|
+
const bus = getBusProvider(dynamicModule.providers).useFactory();
|
|
77
|
+
const closeSpy = jest.spyOn(bus, 'close');
|
|
78
|
+
await new EventBusModule().onApplicationShutdown();
|
|
79
|
+
expect(closeSpy).toHaveBeenCalled();
|
|
80
|
+
expect(EventBusRegistry.getBus()).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('when disabled', ()=>{
|
|
84
|
+
it('should still export the bus instance so injecting it keeps working', ()=>{
|
|
85
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
86
|
+
enabled: false
|
|
87
|
+
});
|
|
88
|
+
expect(dynamicModule.exports).toEqual([
|
|
89
|
+
EVENT_BUS_INSTANCE,
|
|
90
|
+
EVENT_BUS_MODULE_OPTIONS
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
it('should provide an inert bus', ()=>{
|
|
94
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
95
|
+
enabled: false
|
|
96
|
+
});
|
|
97
|
+
const bus = getBusProvider(dynamicModule.providers).useFactory();
|
|
98
|
+
expect(bus).toBeInstanceOf(DisabledEventBus);
|
|
99
|
+
});
|
|
100
|
+
it('should not register the explorer, so no handler is ever bound', ()=>{
|
|
101
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
102
|
+
enabled: false
|
|
103
|
+
});
|
|
104
|
+
expect(dynamicModule.providers).not.toContain(EventBusExplorer);
|
|
105
|
+
});
|
|
106
|
+
it('should not import DiscoveryModule', ()=>{
|
|
107
|
+
expect(EventBusModule.forRoot({
|
|
108
|
+
enabled: false
|
|
109
|
+
}).imports).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
it('should leave the registry empty so every publish becomes a no-op', ()=>{
|
|
112
|
+
const dynamicModule = EventBusModule.forRoot({
|
|
113
|
+
enabled: false
|
|
114
|
+
});
|
|
115
|
+
getBusProvider(dynamicModule.providers).useFactory();
|
|
116
|
+
expect(EventBusRegistry.getBus()).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
it('should shut down cleanly with no bus registered', async ()=>{
|
|
119
|
+
EventBusModule.forRoot({
|
|
120
|
+
enabled: false
|
|
121
|
+
});
|
|
122
|
+
await expect(new EventBusModule().onApplicationShutdown()).resolves.toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('default from the environment', ()=>{
|
|
126
|
+
it('should be enabled when ENABLE_DOMAIN_EVENTS is true', ()=>{
|
|
127
|
+
process.env.ENABLE_DOMAIN_EVENTS = 'true';
|
|
128
|
+
expect(EventBusModule.forRoot().providers).toContain(EventBusExplorer);
|
|
129
|
+
});
|
|
130
|
+
it('should be disabled when ENABLE_DOMAIN_EVENTS is false', ()=>{
|
|
131
|
+
process.env.ENABLE_DOMAIN_EVENTS = 'false';
|
|
132
|
+
expect(EventBusModule.forRoot().providers).not.toContain(EventBusExplorer);
|
|
133
|
+
});
|
|
134
|
+
it('should be disabled when ENABLE_DOMAIN_EVENTS is not set', ()=>{
|
|
135
|
+
delete process.env.ENABLE_DOMAIN_EVENTS;
|
|
136
|
+
expect(EventBusModule.forRoot().providers).not.toContain(EventBusExplorer);
|
|
137
|
+
});
|
|
138
|
+
it('should let an explicit option override the environment', ()=>{
|
|
139
|
+
process.env.ENABLE_DOMAIN_EVENTS = 'false';
|
|
140
|
+
expect(EventBusModule.forRoot({
|
|
141
|
+
enabled: true
|
|
142
|
+
}).providers).toContain(EventBusExplorer);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EventBusRegistry } from '../../classes/event-bus.registry';
|
|
2
|
+
export const moduleEventsToken = (moduleName)=>`${moduleName.toUpperCase().replace(/-/g, '_')}_EVENTS_CONFIG`;
|
|
3
|
+
const providerCache = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Registers a feature package's `events` block so every ApiService in that
|
|
6
|
+
* module starts publishing. Add it to the module's own providers:
|
|
7
|
+
*
|
|
8
|
+
* providers: [createModuleEventsProvider('storage', STORAGE_MODULE_OPTIONS)]
|
|
9
|
+
*/ export function createModuleEventsProvider(moduleName, optionsToken) {
|
|
10
|
+
const cacheKey = `${moduleName}|${optionsToken}`;
|
|
11
|
+
const cached = providerCache.get(cacheKey);
|
|
12
|
+
if (cached) return cached;
|
|
13
|
+
const provider = {
|
|
14
|
+
provide: moduleEventsToken(moduleName),
|
|
15
|
+
useFactory: (options)=>{
|
|
16
|
+
const events = options?.config?.events ?? {};
|
|
17
|
+
EventBusRegistry.configureModule(moduleName, events);
|
|
18
|
+
return events;
|
|
19
|
+
},
|
|
20
|
+
inject: [
|
|
21
|
+
optionsToken
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
providerCache.set(cacheKey, provider);
|
|
25
|
+
return provider;
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { EventBusRegistry } from '../../classes/event-bus.registry';
|
|
2
|
+
import { createModuleEventsProvider, moduleEventsToken } from './module-events.provider';
|
|
3
|
+
describe('moduleEventsToken', ()=>{
|
|
4
|
+
it('should build an upper snake case token', ()=>{
|
|
5
|
+
expect(moduleEventsToken('storage')).toBe('STORAGE_EVENTS_CONFIG');
|
|
6
|
+
expect(moduleEventsToken('form-builder')).toBe('FORM_BUILDER_EVENTS_CONFIG');
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
describe('createModuleEventsProvider', ()=>{
|
|
10
|
+
beforeEach(()=>{
|
|
11
|
+
EventBusRegistry.reset();
|
|
12
|
+
});
|
|
13
|
+
it('should inject the module options token', ()=>{
|
|
14
|
+
const provider = createModuleEventsProvider('storage', 'STORAGE_MODULE_OPTIONS');
|
|
15
|
+
expect(provider.provide).toBe('STORAGE_EVENTS_CONFIG');
|
|
16
|
+
expect(provider.inject).toEqual([
|
|
17
|
+
'STORAGE_MODULE_OPTIONS'
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
it('should register the events block from the module config', ()=>{
|
|
21
|
+
const provider = createModuleEventsProvider('storage', 'STORAGE_MODULE_OPTIONS');
|
|
22
|
+
const result = provider.useFactory({
|
|
23
|
+
config: {
|
|
24
|
+
events: {
|
|
25
|
+
enabled: true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
expect(result).toEqual({
|
|
30
|
+
enabled: true
|
|
31
|
+
});
|
|
32
|
+
expect(EventBusRegistry.getModuleConfig('storage')).toEqual({
|
|
33
|
+
enabled: true
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it('should register an empty config when the module supplies no events block', ()=>{
|
|
37
|
+
const provider = createModuleEventsProvider('auth', 'AUTH_MODULE_OPTIONS');
|
|
38
|
+
provider.useFactory({
|
|
39
|
+
config: {}
|
|
40
|
+
});
|
|
41
|
+
expect(EventBusRegistry.getModuleConfig('auth')).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
it('should return the same provider for repeated calls so module comparisons stay stable', ()=>{
|
|
44
|
+
const first = createModuleEventsProvider('event-manager', 'EVENT_MANAGER_MODULE_OPTIONS');
|
|
45
|
+
const second = createModuleEventsProvider('event-manager', 'EVENT_MANAGER_MODULE_OPTIONS');
|
|
46
|
+
expect(first).toBe(second);
|
|
47
|
+
});
|
|
48
|
+
it('should tolerate missing options', ()=>{
|
|
49
|
+
const provider = createModuleEventsProvider('iam', 'IAM_MODULE_OPTIONS');
|
|
50
|
+
expect(provider.useFactory(undefined)).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
});
|
package/fesm/modules/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export * from './cache/cache.module';
|
|
2
2
|
export * from './datasource';
|
|
3
|
+
export * from './event-bus/event-bus.explorer';
|
|
4
|
+
export * from './event-bus/event-bus.module';
|
|
5
|
+
export * from './event-bus/module-events.provider';
|
|
3
6
|
export * from './shared-permission-cache/shared-permission-cache.module';
|
|
4
7
|
export * from './shared-permission-cache/shared-permission-cache.service';
|
|
5
8
|
export * from './utils/utils.module';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const SENSITIVE_KEYS = [
|
|
2
|
+
'password',
|
|
3
|
+
'secret',
|
|
4
|
+
'token',
|
|
5
|
+
'apikey',
|
|
6
|
+
'authorization',
|
|
7
|
+
'salt',
|
|
8
|
+
'hash',
|
|
9
|
+
'credential',
|
|
10
|
+
'accesskey',
|
|
11
|
+
'accountkey',
|
|
12
|
+
'privatekey',
|
|
13
|
+
'connectionstring',
|
|
14
|
+
'passphrase'
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Matched whole, not as a substring - `pass` must be redacted without also
|
|
18
|
+
* redacting `bypassCache`, and a storage row's `key` is a path, not a secret.
|
|
19
|
+
*/ const SENSITIVE_EXACT_KEYS = [
|
|
20
|
+
'pass',
|
|
21
|
+
'pwd'
|
|
22
|
+
];
|
|
23
|
+
const REDACTED = '[REDACTED]';
|
|
24
|
+
const CIRCULAR = '[CIRCULAR]';
|
|
25
|
+
const TRUNCATED = '[TRUNCATED]';
|
|
26
|
+
const BINARY = '[BINARY]';
|
|
27
|
+
const MAX_DEPTH = 8;
|
|
28
|
+
/**
|
|
29
|
+
* Matches a dot separated event name against a routing pattern.
|
|
30
|
+
* `*` matches exactly one segment, `#` and `**` match zero or more segments.
|
|
31
|
+
*/ export function matchEventPattern(pattern, eventName) {
|
|
32
|
+
if (pattern === eventName) return true;
|
|
33
|
+
const patternSegments = pattern.split('.');
|
|
34
|
+
const nameSegments = eventName.split('.');
|
|
35
|
+
return matchSegments(patternSegments, 0, nameSegments, 0);
|
|
36
|
+
}
|
|
37
|
+
export function isMultiWildcard(segment) {
|
|
38
|
+
return segment === '#' || segment === '**';
|
|
39
|
+
}
|
|
40
|
+
function matchSegments(pattern, patternIndex, name, nameIndex) {
|
|
41
|
+
if (patternIndex === pattern.length) return nameIndex === name.length;
|
|
42
|
+
const segment = pattern[patternIndex];
|
|
43
|
+
if (isMultiWildcard(segment)) {
|
|
44
|
+
for(let skip = nameIndex; skip <= name.length; skip++){
|
|
45
|
+
if (matchSegments(pattern, patternIndex + 1, name, skip)) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (nameIndex === name.length) return false;
|
|
50
|
+
if (segment !== '*' && segment !== name[nameIndex]) return false;
|
|
51
|
+
return matchSegments(pattern, patternIndex + 1, name, nameIndex + 1);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Strips credential-like fields before a row leaves the process on an event.
|
|
55
|
+
*
|
|
56
|
+
* Saved TypeORM rows routinely carry bidirectional relations, so the walk
|
|
57
|
+
* tracks the current path and caps its depth - a cycle or a very deep graph
|
|
58
|
+
* must never turn an event into a stack overflow.
|
|
59
|
+
*/ export function sanitizeEventPayload(value) {
|
|
60
|
+
return sanitizeValue(value, new WeakSet(), 0);
|
|
61
|
+
}
|
|
62
|
+
function sanitizeValue(value, ancestors, depth) {
|
|
63
|
+
if (value === null || typeof value !== 'object') return value;
|
|
64
|
+
if (value instanceof Date) return value;
|
|
65
|
+
if (Buffer.isBuffer(value)) return BINARY;
|
|
66
|
+
if (ancestors.has(value)) return CIRCULAR;
|
|
67
|
+
if (depth >= MAX_DEPTH) return TRUNCATED;
|
|
68
|
+
ancestors.add(value);
|
|
69
|
+
try {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.map((item)=>sanitizeValue(item, ancestors, depth + 1));
|
|
72
|
+
}
|
|
73
|
+
const sanitized = {};
|
|
74
|
+
for (const [key, entry] of Object.entries(value)){
|
|
75
|
+
sanitized[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(entry, ancestors, depth + 1);
|
|
76
|
+
}
|
|
77
|
+
return sanitized;
|
|
78
|
+
} finally{
|
|
79
|
+
ancestors.delete(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function isSensitiveKey(key) {
|
|
83
|
+
const lowered = key.toLowerCase();
|
|
84
|
+
if (SENSITIVE_EXACT_KEYS.includes(lowered)) return true;
|
|
85
|
+
return SENSITIVE_KEYS.some((sensitive)=>lowered.includes(sensitive));
|
|
86
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { isMultiWildcard, matchEventPattern, sanitizeEventPayload } from './event-pattern.util';
|
|
2
|
+
describe('matchEventPattern', ()=>{
|
|
3
|
+
it('should match an exact event name', ()=>{
|
|
4
|
+
expect(matchEventPattern('storage.folder.created', 'storage.folder.created')).toBe(true);
|
|
5
|
+
});
|
|
6
|
+
it('should not match a different event name', ()=>{
|
|
7
|
+
expect(matchEventPattern('storage.folder.created', 'storage.folder.updated')).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
it('should treat * as exactly one segment', ()=>{
|
|
10
|
+
expect(matchEventPattern('storage.*.created', 'storage.folder.created')).toBe(true);
|
|
11
|
+
expect(matchEventPattern('storage.*', 'storage.folder.created')).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it('should treat # and ** as any number of segments', ()=>{
|
|
14
|
+
expect(matchEventPattern('storage.#', 'storage.folder.created')).toBe(true);
|
|
15
|
+
expect(matchEventPattern('**', 'auth.appuser.deleted')).toBe(true);
|
|
16
|
+
expect(matchEventPattern('storage.**', 'storage.folder.created')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('should let a multi wildcard match zero segments', ()=>{
|
|
19
|
+
expect(matchEventPattern('storage.folder.created.#', 'storage.folder.created')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('should not match across a different module', ()=>{
|
|
22
|
+
expect(matchEventPattern('storage.#', 'auth.appuser.created')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('isMultiWildcard', ()=>{
|
|
26
|
+
it.each([
|
|
27
|
+
[
|
|
28
|
+
'#',
|
|
29
|
+
true
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
'**',
|
|
33
|
+
true
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
'*',
|
|
37
|
+
false
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
'folder',
|
|
41
|
+
false
|
|
42
|
+
]
|
|
43
|
+
])('should classify %s as %s', (segment, expected)=>{
|
|
44
|
+
expect(isMultiWildcard(segment)).toBe(expected);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('sanitizeEventPayload', ()=>{
|
|
48
|
+
it('should redact credential-like fields', ()=>{
|
|
49
|
+
const result = sanitizeEventPayload({
|
|
50
|
+
id: '1',
|
|
51
|
+
password: 'secret',
|
|
52
|
+
apiKey: 'k'
|
|
53
|
+
});
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
id: '1',
|
|
56
|
+
password: '[REDACTED]',
|
|
57
|
+
apiKey: '[REDACTED]'
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it('should redact the credential keys used by the storage and email providers', ()=>{
|
|
61
|
+
const result = sanitizeEventPayload({
|
|
62
|
+
name: 'primary-s3',
|
|
63
|
+
config: {
|
|
64
|
+
region: 'us-east-1',
|
|
65
|
+
bucket: 'assets',
|
|
66
|
+
accessKeyId: 'AKIA...',
|
|
67
|
+
secretAccessKey: 's3cr3t',
|
|
68
|
+
accountKey: 'azure-key',
|
|
69
|
+
privateKey: '-----BEGIN...',
|
|
70
|
+
connectionString: 'DefaultEndpointsProtocol=...;AccountKey=abc',
|
|
71
|
+
auth: {
|
|
72
|
+
user: 'mailer',
|
|
73
|
+
pass: 'hunter2'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
name: 'primary-s3',
|
|
79
|
+
config: {
|
|
80
|
+
region: 'us-east-1',
|
|
81
|
+
bucket: 'assets',
|
|
82
|
+
accessKeyId: '[REDACTED]',
|
|
83
|
+
secretAccessKey: '[REDACTED]',
|
|
84
|
+
accountKey: '[REDACTED]',
|
|
85
|
+
privateKey: '[REDACTED]',
|
|
86
|
+
connectionString: '[REDACTED]',
|
|
87
|
+
auth: {
|
|
88
|
+
user: 'mailer',
|
|
89
|
+
pass: '[REDACTED]'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
it('should not over-redact keys that merely contain a credential word', ()=>{
|
|
95
|
+
const result = sanitizeEventPayload({
|
|
96
|
+
key: 'uploads/2026/report.pdf',
|
|
97
|
+
bypassCache: true,
|
|
98
|
+
passengerCount: 4
|
|
99
|
+
});
|
|
100
|
+
expect(result).toEqual({
|
|
101
|
+
key: 'uploads/2026/report.pdf',
|
|
102
|
+
bypassCache: true,
|
|
103
|
+
passengerCount: 4
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
it('should redact nested fields', ()=>{
|
|
107
|
+
const result = sanitizeEventPayload({
|
|
108
|
+
user: {
|
|
109
|
+
name: 'Ada',
|
|
110
|
+
passwordHash: 'x'
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
user: {
|
|
115
|
+
name: 'Ada',
|
|
116
|
+
passwordHash: '[REDACTED]'
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it('should map over arrays', ()=>{
|
|
121
|
+
const result = sanitizeEventPayload([
|
|
122
|
+
{
|
|
123
|
+
token: 't'
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
token: 'u'
|
|
127
|
+
}
|
|
128
|
+
]);
|
|
129
|
+
expect(result).toEqual([
|
|
130
|
+
{
|
|
131
|
+
token: '[REDACTED]'
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
token: '[REDACTED]'
|
|
135
|
+
}
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
it('should leave dates and primitives untouched', ()=>{
|
|
139
|
+
const date = new Date('2026-01-01T00:00:00.000Z');
|
|
140
|
+
expect(sanitizeEventPayload({
|
|
141
|
+
createdAt: date,
|
|
142
|
+
count: 3
|
|
143
|
+
})).toEqual({
|
|
144
|
+
createdAt: date,
|
|
145
|
+
count: 3
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it('should mark a circular relation instead of recursing forever', ()=>{
|
|
149
|
+
const folder = {
|
|
150
|
+
id: 'folder-1',
|
|
151
|
+
name: 'Docs'
|
|
152
|
+
};
|
|
153
|
+
const file = {
|
|
154
|
+
id: 'file-1',
|
|
155
|
+
folder
|
|
156
|
+
};
|
|
157
|
+
folder.files = [
|
|
158
|
+
file
|
|
159
|
+
];
|
|
160
|
+
expect(sanitizeEventPayload(folder)).toEqual({
|
|
161
|
+
id: 'folder-1',
|
|
162
|
+
name: 'Docs',
|
|
163
|
+
files: [
|
|
164
|
+
{
|
|
165
|
+
id: 'file-1',
|
|
166
|
+
folder: '[CIRCULAR]'
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
it('should survive a self referencing row', ()=>{
|
|
172
|
+
const row = {
|
|
173
|
+
id: '1'
|
|
174
|
+
};
|
|
175
|
+
row.self = row;
|
|
176
|
+
expect(()=>sanitizeEventPayload(row)).not.toThrow();
|
|
177
|
+
expect(sanitizeEventPayload(row)).toEqual({
|
|
178
|
+
id: '1',
|
|
179
|
+
self: '[CIRCULAR]'
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it('should repeat a shared row rather than calling the second one circular', ()=>{
|
|
183
|
+
const shared = {
|
|
184
|
+
id: 'shared'
|
|
185
|
+
};
|
|
186
|
+
expect(sanitizeEventPayload([
|
|
187
|
+
{
|
|
188
|
+
a: shared
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
b: shared
|
|
192
|
+
}
|
|
193
|
+
])).toEqual([
|
|
194
|
+
{
|
|
195
|
+
a: {
|
|
196
|
+
id: 'shared'
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
b: {
|
|
201
|
+
id: 'shared'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
it('should truncate a graph deeper than the depth cap', ()=>{
|
|
207
|
+
const deep = {};
|
|
208
|
+
let cursor = deep;
|
|
209
|
+
for(let level = 0; level < 12; level++){
|
|
210
|
+
const next = {};
|
|
211
|
+
cursor.child = next;
|
|
212
|
+
cursor = next;
|
|
213
|
+
}
|
|
214
|
+
let result = sanitizeEventPayload(deep);
|
|
215
|
+
for(let level = 0; level < 7; level++){
|
|
216
|
+
result = result.child;
|
|
217
|
+
}
|
|
218
|
+
expect(result.child).toBe('[TRUNCATED]');
|
|
219
|
+
});
|
|
220
|
+
it('should replace a buffer with a marker instead of exploding it into indexes', ()=>{
|
|
221
|
+
const result = sanitizeEventPayload({
|
|
222
|
+
id: '1',
|
|
223
|
+
content: Buffer.from('hello')
|
|
224
|
+
});
|
|
225
|
+
expect(result).toEqual({
|
|
226
|
+
id: '1',
|
|
227
|
+
content: '[BINARY]'
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
package/fesm/utils/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './date-time.util';
|
|
2
|
+
export * from './event-pattern.util';
|
|
2
3
|
export * from './html-sanitizer.util';
|
|
4
|
+
export * from './optional-module.util';
|
|
3
5
|
export * from './permission-cache-key.util';
|
|
4
6
|
export * from './query-helpers.util';
|
|
5
7
|
export * from './request.util';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads an optional peer dependency at runtime.
|
|
3
|
+
*
|
|
4
|
+
* The specifier has to stay invisible to the bundler. A plain `import(name)`
|
|
5
|
+
* looks dynamic in source but webpack rewrites it into a context module rooted
|
|
6
|
+
* at this directory - one that can only ever resolve files sitting next to this
|
|
7
|
+
* one - so `amqplib` and `kafkajs` fail to load in the bundled app no matter how
|
|
8
|
+
* correctly they are installed. Both lookups below are assembled at runtime,
|
|
9
|
+
* where no bundler can rewrite them.
|
|
10
|
+
*
|
|
11
|
+
* `require` is used first because it is what the CommonJS builds actually run
|
|
12
|
+
* on; native ESM has no `require`, and there `import()` is the only way in.
|
|
13
|
+
*/ export async function loadOptionalModule(name) {
|
|
14
|
+
const runtimeRequire = resolveRuntimeRequire();
|
|
15
|
+
if (runtimeRequire) return runtimeRequire(name);
|
|
16
|
+
const runtimeImport = new Function('specifier', 'return import(specifier);');
|
|
17
|
+
return runtimeImport(name);
|
|
18
|
+
}
|
|
19
|
+
/** Undefined under native ESM and inside a bundle that shadows nothing back */ function resolveRuntimeRequire() {
|
|
20
|
+
try {
|
|
21
|
+
return eval('typeof require === "function" ? require : undefined');
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadOptionalModule } from './optional-module.util';
|
|
2
|
+
describe('loadOptionalModule', ()=>{
|
|
3
|
+
it('should resolve an installed module', async ()=>{
|
|
4
|
+
const events = await loadOptionalModule('events');
|
|
5
|
+
expect(typeof events.EventEmitter).toBe('function');
|
|
6
|
+
});
|
|
7
|
+
it('should reject for a module that is not installed', async ()=>{
|
|
8
|
+
await expect(loadOptionalModule('definitely-not-installed-pkg')).rejects.toThrow();
|
|
9
|
+
});
|
|
10
|
+
});
|