Skip to content

Commit 17dce65

Browse files
committed
feat(declarative-services): add support for decorator extenders
1 parent 5bcf899 commit 17dce65

File tree

8 files changed

+528
-5
lines changed

8 files changed

+528
-5
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Creating Decorator Extenders
2+
3+
Build small extenders that react to component decorators and wire your own features (UI, views, routing, etc.) without re‑implementing SCR.
4+
5+
What you use:
6+
- getDecoratorInfo(target) — one call for all decorator metadata.
7+
- EventAdmin — SCR posts component lifecycle events carrying DecoratorInfo.
8+
- Custom metadata — read your own reflect‑metadata without hard‑coding names.
9+
10+
See also: docs/extender-pattern.md (concepts) and docs/whiteboard-pattern.md (EventAdmin).
11+
12+
## Event‑driven micro‑extender
13+
Subscribe to SCR topics so your extender reacts as components appear/change without re‑scanning.
14+
15+
Topics to handle:
16+
- scr/component/registered
17+
- scr/component/activated
18+
- scr/component/deactivated
19+
- scr/component/removed
20+
- scr/component/config-updated
21+
22+
Event properties (recommended):
23+
- bundle.id: number
24+
- bundle.symbolicName?: string
25+
- component.name: string
26+
- decorators: DecoratorInfo (from getDecoratorInfo)
27+
- component, service, configuration, lifecycle, references, rawMetadata,
28+
- customDecorators, customFieldDecorators, customMethodDecorators
29+
30+
Handler example:
31+
32+
```ts
33+
import type { EventHandler, Event } from '@pandino/pandino';
34+
import { Component, Service, Property } from '@pandino/decorators';
35+
36+
@Component({ name: 'custom.decorator.extender' })
37+
@Service({ interfaces: ['EventHandler'] })
38+
@Property('event.topics', 'scr/component/*')
39+
export class CustomDecoratorExtender implements EventHandler {
40+
handleEvent(event: Event): void {
41+
const info = event.getProperty('decorators'); // DecoratorInfo
42+
43+
// Class-level metadata
44+
const cls = info?.customDecorators || {};
45+
// Field-level metadata per field name
46+
const fields = info?.customFieldDecorators || {};
47+
// Method-level metadata per method name
48+
const methods = info?.customMethodDecorators || {};
49+
50+
// Example: react on registration only
51+
if (event.getTopic().endsWith('/registered')) {
52+
if (cls['my:feature']) {
53+
// wire your feature based on class-level metadata
54+
}
55+
if (fields.logger?.['my:field']) {
56+
// set up logger UI hook, etc.
57+
}
58+
if (methods.doWork?.['my:method']) {
59+
// wrap/augment a method behavior
60+
}
61+
}
62+
}
63+
}
64+
```
65+
66+
## Reading custom metadata (class, field, method)
67+
Define decorators in your app that store reflect‑metadata; SCR exposes them via DecoratorInfo.
68+
69+
```ts
70+
import 'reflect-metadata';
71+
72+
export function Feature(meta: any) {
73+
return (target: Function) => Reflect.defineMetadata('my:feature', meta, target);
74+
}
75+
export function FieldFeature(meta: any) {
76+
return (target: any, key: string | symbol) => Reflect.defineMetadata('my:field', meta, target, key);
77+
}
78+
export function MethodFeature(meta: any) {
79+
return (target: any, key: string | symbol, _d: PropertyDescriptor) =>
80+
Reflect.defineMetadata('my:method', meta, target, key);
81+
}
82+
83+
@Feature({ view: 'react', tpl: 'page' })
84+
class PageComponent {
85+
@FieldFeature({ role: 'logger' }) private logger?: any;
86+
@MethodFeature({ role: 'op' }) doWork() {}
87+
}
88+
```
89+
90+
Access in handler:
91+
92+
```ts
93+
const info = event.getProperty('decorators');
94+
const cls = info.customDecorators['my:feature'];
95+
const fld = info.customFieldDecorators['logger']?.['my:field'];
96+
const mtd = info.customMethodDecorators['doWork']?.['my:method'];
97+
```
98+
99+
Notes:
100+
- design:* and Pandino internal keys are filtered out from these maps.
101+
- Field names are inferred from DS references when present.
102+
103+
## Best practices
104+
- Keep extenders tiny and single‑purpose; filter early by topic/properties.
105+
- Cache results; invalidate on deactivated/removed.
106+
- Offload heavy work; use postEvent for async pipelines.
107+
- Prefer Whiteboard style: register as services and let the framework route events.
108+
109+
## References
110+
- docs/extender-pattern.md — Conceptual overview of SCR as an extender
111+
- docs/whiteboard-pattern.md — EventAdmin and event handling
112+
- packages/pandino/src/services/declarative-services/reflection.ts — getDecoratorInfo helpers
113+
- packages/decorators — Core decorator package (Component, Service, Property, ...)

docs/extender-pattern.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,6 @@ In this example, SCR (the extender) handles:
126126
## Conclusion
127127

128128
The Extender Pattern, implemented through SCR in Pandino, brings the power of OSGi's dynamic modularity to TypeScript and browser environments. It enables a declarative approach to service components, reducing boilerplate code and allowing developers to focus on business logic while the framework handles the complex service lifecycle management.
129+
130+
## See also
131+
- Creating Decorator Extenders (Micro‑Extenders): ./creating-decorator-extenders.md

packages/pandino/src/services/declarative-services/__tests__/__snapshots__/reflection.test.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ exports[`Reflection Helpers > getDecoratorInfo > should retrieve all decorator i
1919
"service.ranking": 100,
2020
},
2121
},
22+
"customDecorators": {},
23+
"customFieldDecorators": {},
24+
"customMethodDecorators": {},
2225
"lifecycle": {
2326
"activate": "activate",
2427
"deactivate": "deactivate",
@@ -104,6 +107,9 @@ exports[`Reflection Helpers > getDecoratorInfo > should work with non-component
104107
"policy": "optional",
105108
"properties": {},
106109
},
110+
"customDecorators": {},
111+
"customFieldDecorators": {},
112+
"customMethodDecorators": {},
107113
"lifecycle": {
108114
"activate": null,
109115
"deactivate": null,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import 'reflect-metadata';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
import { OSGiBootstrap } from '~/framework/bootstrap';
4+
import type { EventAdmin, EventHandler } from '~/services/event-admin/interfaces';
5+
import { Event } from '~/services/event-admin/interfaces';
6+
import { ServiceComponentRuntime } from '../scr';
7+
import { Component, Service, Activate, Deactivate, Reference } from '@pandino/decorators';
8+
import { getDecoratorInfo } from '../reflection';
9+
10+
function Feature(meta: any) {
11+
return function (target: Function) {
12+
Reflect.defineMetadata('my:feature', meta, target);
13+
};
14+
}
15+
16+
function FieldFeature(meta: any) {
17+
return function (target: any, propertyKey: string | symbol) {
18+
Reflect.defineMetadata('my:field', meta, target, propertyKey);
19+
};
20+
}
21+
22+
function MethodFeature(meta: any) {
23+
return function (target: any, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
24+
Reflect.defineMetadata('my:method', meta, target, propertyKey);
25+
};
26+
}
27+
28+
function sleep(ms: number) {
29+
return new Promise((resolve) => setTimeout(resolve, ms));
30+
}
31+
32+
describe('Creating Decorator Extenders (Micro-Extenders) - Integration', () => {
33+
let bootstrap: OSGiBootstrap;
34+
35+
beforeEach(async () => {
36+
bootstrap = new OSGiBootstrap();
37+
await bootstrap.start();
38+
});
39+
40+
afterEach(async () => {
41+
await bootstrap.stop();
42+
});
43+
44+
it('should deliver SCR events with DecoratorInfo to EventHandler subscribed to scr/component/*', async () => {
45+
const framework = bootstrap.getFramework();
46+
const context = framework.getBundleContext();
47+
48+
// Register an EventHandler service subscribing to all SCR component events
49+
const received: { topic: string; event: Event }[] = [];
50+
const handler: EventHandler = {
51+
handleEvent: (event: Event) => {
52+
received.push({ topic: event.getTopic(), event });
53+
},
54+
};
55+
context.registerService('EventHandler', handler, { 'event.topics': 'scr/component/*' });
56+
57+
const eaRef = context.getServiceReference<EventAdmin>('EventAdmin');
58+
expect(eaRef).toBeDefined();
59+
const eventAdmin = context.getService<EventAdmin>(eaRef!);
60+
expect(eventAdmin).toBeDefined();
61+
62+
const scr = new ServiceComponentRuntime(framework, context);
63+
64+
@Feature({ view: 'react', tpl: 'page' })
65+
@Component({ name: 'custom.decorator.extender.test', immediate: false, configurationPid: 'extender.pid' })
66+
@Service({ interfaces: ['TestIface'] })
67+
class ExtenderTestComponent {
68+
activated = false;
69+
deactivated = false;
70+
71+
@Activate
72+
activate() {
73+
this.activated = true;
74+
}
75+
76+
@Deactivate
77+
deactivate() {
78+
this.deactivated = true;
79+
}
80+
}
81+
82+
const bundleId = context.getBundle().getBundleId();
83+
84+
await scr.registerComponent(ExtenderTestComponent, bundleId);
85+
await sleep(20); // allow async EventAdmin.postEvent delivery
86+
87+
const reg = received.find((r) => r.topic === 'scr/component/registered');
88+
expect(reg).toBeDefined();
89+
expect(reg!.event.getProperty('bundle.id')).toBe(bundleId);
90+
expect(reg!.event.getProperty('component.name')).toBe('custom.decorator.extender.test');
91+
const regDecorators = reg!.event.getProperty('decorators');
92+
expect(regDecorators).toBeDefined();
93+
expect(Object.keys(regDecorators)).toEqual(
94+
expect.arrayContaining([
95+
'component',
96+
'service',
97+
'configuration',
98+
'lifecycle',
99+
'references',
100+
'rawMetadata',
101+
'customDecorators',
102+
]),
103+
);
104+
expect(regDecorators.customDecorators['my:feature']).toEqual({ view: 'react', tpl: 'page' });
105+
// Ensure no design:* keys leaked through
106+
for (const k of Object.keys(regDecorators.customDecorators)) {
107+
expect(k.startsWith('design:')).toBe(false);
108+
}
109+
110+
// Activate component -> expect activated
111+
await scr.activateComponent(bundleId, 'custom.decorator.extender.test');
112+
await sleep(10);
113+
114+
const act = received.find((r) => r.topic === 'scr/component/activated');
115+
expect(act).toBeDefined();
116+
const actDecorators = act!.event.getProperty('decorators');
117+
expect(actDecorators.customDecorators['my:feature']).toEqual({ view: 'react', tpl: 'page' });
118+
119+
// Update configuration -> expect config-updated
120+
await scr.updateComponentConfiguration(bundleId, 'custom.decorator.extender.test', { foo: 'bar' });
121+
await sleep(10);
122+
const cfg = received.find((r) => r.topic === 'scr/component/config-updated');
123+
expect(cfg).toBeDefined();
124+
expect(cfg!.event.getProperty('configuration')).toEqual({ foo: 'bar' });
125+
const cfgDecorators = cfg!.event.getProperty('decorators');
126+
expect(cfgDecorators.customDecorators['my:feature']).toEqual({ view: 'react', tpl: 'page' });
127+
128+
// Deactivate -> expect deactivated
129+
await scr.deactivateComponent(bundleId, 'custom.decorator.extender.test');
130+
await sleep(10);
131+
const deact = received.find((r) => r.topic === 'scr/component/deactivated');
132+
expect(deact).toBeDefined();
133+
134+
// Remove -> expect removed
135+
await scr.removeBundleComponents(bundleId);
136+
await sleep(10);
137+
const rem = received.find((r) => r.topic === 'scr/component/removed');
138+
expect(rem).toBeDefined();
139+
});
140+
141+
it('getDecoratorInfo should expose customDecorators including custom keys and exclude design:*', () => {
142+
@Feature({ view: 'react', tpl: 'page' })
143+
@Component({ name: 'page.component' })
144+
class PageComponent {}
145+
146+
const info = getDecoratorInfo(PageComponent);
147+
expect(info).toBeDefined();
148+
// Verify structure keys
149+
expect(Object.keys(info)).toEqual(
150+
expect.arrayContaining([
151+
'component',
152+
'service',
153+
'configuration',
154+
'lifecycle',
155+
'references',
156+
'rawMetadata',
157+
'customDecorators',
158+
]),
159+
);
160+
161+
// Verify custom metadata inclusion
162+
expect(info.customDecorators['my:feature']).toEqual({ view: 'react', tpl: 'page' });
163+
164+
// Ensure TS design:* metadata and internal keys are not present in customDecorators
165+
for (const k of Object.keys(info.customDecorators)) {
166+
expect(k.startsWith('design:')).toBe(false);
167+
}
168+
});
169+
170+
it('should expose customFieldDecorators and customMethodDecorators via getDecoratorInfo', async () => {
171+
@Component({ name: 'field.method.component' })
172+
@Service({ interfaces: ['Dummy'] })
173+
class FieldMethodComponent {
174+
@Reference({ interface: 'LogService' })
175+
@FieldFeature({ role: 'logger' })
176+
private logger?: any;
177+
178+
@MethodFeature({ role: 'op' })
179+
doWork() {}
180+
181+
@Activate
182+
activate() {}
183+
}
184+
185+
const info = getDecoratorInfo(FieldMethodComponent);
186+
expect(info.customFieldDecorators).toBeDefined();
187+
expect(info.customMethodDecorators).toBeDefined();
188+
189+
expect(info.customFieldDecorators['logger']).toBeDefined();
190+
expect(info.customFieldDecorators['logger']['my:field']).toEqual({ role: 'logger' });
191+
192+
expect(info.customMethodDecorators['doWork']).toBeDefined();
193+
expect(info.customMethodDecorators['doWork']['my:method']).toEqual({ role: 'op' });
194+
});
195+
196+
it('should include customFieldDecorators and customMethodDecorators in SCR events', async () => {
197+
const framework = bootstrap.getFramework();
198+
const context = framework.getBundleContext();
199+
200+
const received: { topic: string; event: Event }[] = [];
201+
const handler: EventHandler = {
202+
handleEvent: (event: Event) => {
203+
received.push({ topic: event.getTopic(), event });
204+
},
205+
};
206+
context.registerService('EventHandler', handler, { 'event.topics': 'scr/component/*' });
207+
208+
const eaRef = context.getServiceReference<EventAdmin>('EventAdmin');
209+
expect(eaRef).toBeDefined();
210+
const eventAdmin = context.getService<EventAdmin>(eaRef!);
211+
expect(eventAdmin).toBeDefined();
212+
213+
const scr = new ServiceComponentRuntime(framework, context);
214+
215+
@Component({ name: 'field.method.events.component' })
216+
class FieldMethodEventsComponent {
217+
@Reference({ interface: 'LogService' })
218+
@FieldFeature({ role: 'logger' })
219+
private logger?: any;
220+
221+
@MethodFeature({ role: 'op' })
222+
doWork() {}
223+
224+
@Activate
225+
activate() {}
226+
}
227+
228+
const bundleId = context.getBundle().getBundleId();
229+
await scr.registerComponent(FieldMethodEventsComponent, bundleId);
230+
231+
// allow asynchronous EventAdmin delivery
232+
await new Promise((r) => setTimeout(r, 20));
233+
234+
const reg = received.find((r) => r.topic === 'scr/component/registered');
235+
expect(reg).toBeDefined();
236+
const decorators = reg!.event.getProperty('decorators');
237+
expect(decorators).toBeDefined();
238+
239+
expect(decorators.customFieldDecorators['logger']).toBeDefined();
240+
expect(decorators.customFieldDecorators['logger']['my:field']).toEqual({ role: 'logger' });
241+
242+
expect(decorators.customMethodDecorators['doWork']).toBeDefined();
243+
expect(decorators.customMethodDecorators['doWork']['my:method']).toEqual({ role: 'op' });
244+
});
245+
});

0 commit comments

Comments
 (0)