Ajouter @lightweight-sdk/service-registry (packages/common) #2
18 changed files with 543 additions and 4 deletions
5
.changeset/add-service-registry-package.md
Normal file
5
.changeset/add-service-registry-package.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@lightweight-sdk/service-registry": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Ajout du package `@lightweight-sdk/service-registry` : registre de services en singleton avec `@RegisterService()` (enregistrement automatique à la décoration de classe) et `@Service(target)` (injection paresseuse et mémoïsée par référence de classe sur un membre).
|
||||||
49
packages/common/service-registry/README.md
Normal file
49
packages/common/service-registry/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# @lightweight-sdk/service-registry
|
||||||
|
|
||||||
|
Registre de services en singleton, avec enregistrement et injection par décorateurs. Package `common` : isomorphe, aucune dépendance DOM ou Node-only.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @lightweight-sdk/service-registry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { RegisterService, Service } from '@lightweight-sdk/service-registry';
|
||||||
|
|
||||||
|
@RegisterService()
|
||||||
|
class LoggerService {
|
||||||
|
public log(message: string): void {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReportGenerator {
|
||||||
|
@Service(LoggerService)
|
||||||
|
private readonly logger!: LoggerService;
|
||||||
|
|
||||||
|
public run(): void {
|
||||||
|
this.logger.log('generating report');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **`ServiceRepository`** — singleton central qui stocke une instance unique de chaque service, indexée par un uid généré automatiquement (`crypto.randomUUID()`) à l'enregistrement. Accès direct rarement nécessaire : `RegisterService`/`Service` couvrent l'usage courant.
|
||||||
|
- **`@RegisterService()`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository`.
|
||||||
|
- **`@Service(TargetClass)`** — décorateur de propriété. Injecte l'instance partagée de `TargetClass` dans le membre décoré. La résolution est **paresseuse** (au premier accès à la propriété, pas à la décoration) : peu importe que la classe consommatrice soit évaluée avant ou après l'enregistrement du service. Après le premier accès, la propriété est remplacée par la valeur résolue (pas de nouvelle recherche ensuite).
|
||||||
|
|
||||||
|
## Contraintes
|
||||||
|
|
||||||
|
- Une classe décorée par `@RegisterService()` doit avoir un constructeur sans argument obligatoire — ses propres dépendances doivent elles-mêmes être injectées via `@Service`, pas passées au constructeur.
|
||||||
|
- `experimentalDecorators: true` doit être activé côté consommateur (déjà le cas dans ce monorepo via `tsconfig.base.json`).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `RegisterService()` — décorateur de classe.
|
||||||
|
- `Service(target)` — décorateur de propriété.
|
||||||
|
- `ServiceRepository` — singleton (`getInstance()`, `registerClass`, `resolveClass`, `resolve`, `getUidForClass`, `hasClass`). `resetForTesting()` est réservé aux tests.
|
||||||
|
- `ServiceConstructor<T>`, `ServiceUid` — types associés.
|
||||||
23
packages/common/service-registry/package.json
Normal file
23
packages/common/service-registry/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "@lightweight-sdk/service-registry",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Registre de services en singleton avec injection par décorateurs (@RegisterService, @Service).",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "restricted",
|
||||||
|
"registry": "https://git.dev.kyuno.fr/api/packages/kyuno/npm/"
|
||||||
|
},
|
||||||
|
"typedocOptions": {
|
||||||
|
"entryPoints": [
|
||||||
|
"src/index.ts"
|
||||||
|
],
|
||||||
|
"readme": "README.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/common/service-registry/src/index.test.ts
Normal file
24
packages/common/service-registry/src/index.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { RegisterService, Service, ServiceRepository } from './index';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ServiceRepository.resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public API (barrel export)', () => {
|
||||||
|
it('registers and injects a service end to end through the public exports', () => {
|
||||||
|
@RegisterService()
|
||||||
|
class ClockService {
|
||||||
|
public now(): number {
|
||||||
|
return 1234;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Consumer {
|
||||||
|
@Service(ClockService)
|
||||||
|
public readonly clock!: ClockService;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(new Consumer().clock.now()).toBe(1234);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
packages/common/service-registry/src/index.ts
Normal file
4
packages/common/service-registry/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { RegisterService } from './register-service.decorator';
|
||||||
|
export { Service } from './service.decorator';
|
||||||
|
export { ServiceRepository } from './service-repository';
|
||||||
|
export { ServiceConstructor, ServiceUid } from './types';
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { RegisterService } from './register-service.decorator';
|
||||||
|
import { ServiceRepository } from './service-repository';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ServiceRepository.resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RegisterService', () => {
|
||||||
|
it('registers the decorated class in the global repository', () => {
|
||||||
|
@RegisterService()
|
||||||
|
class GreeterService {
|
||||||
|
public greet(name: string): string {
|
||||||
|
return `Hello, ${name}!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
expect(repository.hasClass(GreeterService)).toBe(true);
|
||||||
|
expect(repository.resolveClass(GreeterService)).toBeInstanceOf(GreeterService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares a single instance across every resolution', () => {
|
||||||
|
@RegisterService()
|
||||||
|
class StateService {
|
||||||
|
public value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
repository.resolveClass(StateService).value = 42;
|
||||||
|
|
||||||
|
expect(repository.resolveClass(StateService).value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows if the class is registered twice', () => {
|
||||||
|
class DuplicateService {}
|
||||||
|
RegisterService()(DuplicateService);
|
||||||
|
|
||||||
|
expect(() => RegisterService()(DuplicateService)).toThrow(/already registered/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ServiceRepository } from './service-repository';
|
||||||
|
import type { ServiceConstructor } from './types';
|
||||||
|
|
||||||
|
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class decorator that instantiates the decorated class and registers the
|
||||||
|
* resulting instance in the global {@link ServiceRepository}, making it
|
||||||
|
* resolvable from anywhere via {@link Service}. The decorated class must
|
||||||
|
* have a no-argument constructor — any dependencies it needs should
|
||||||
|
* themselves be injected with {@link Service} rather than passed as
|
||||||
|
* constructor parameters.
|
||||||
|
* @returns a class decorator
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* @RegisterService()
|
||||||
|
* export class LoggerService {
|
||||||
|
* public log(message: string): void {
|
||||||
|
* console.log(message);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function RegisterService(): <T>(target: ServiceConstructor<T>) => void {
|
||||||
|
return <T>(target: ServiceConstructor<T>): void => {
|
||||||
|
try {
|
||||||
|
ServiceRepository.getInstance().registerClass(target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${LOG_PREFIX} Failed to register service "${target.name}".`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { ServiceRepository } from './service-repository';
|
||||||
|
|
||||||
|
class Counter {
|
||||||
|
public value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ServiceRepository.resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ServiceRepository', () => {
|
||||||
|
it('returns the same instance on every call to getInstance', () => {
|
||||||
|
expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a class and resolves the same instance by class reference', () => {
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
|
||||||
|
repository.registerClass(Counter);
|
||||||
|
const first = repository.resolveClass(Counter);
|
||||||
|
const second = repository.resolveClass(Counter);
|
||||||
|
|
||||||
|
expect(first).toBeInstanceOf(Counter);
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a unique uid on registration and resolves the same instance by that uid', () => {
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
|
||||||
|
const uid = repository.registerClass(Counter);
|
||||||
|
|
||||||
|
expect(typeof uid).toBe('string');
|
||||||
|
expect(uid.length).toBeGreaterThan(0);
|
||||||
|
expect(repository.resolve(uid)).toBe(repository.resolveClass(Counter));
|
||||||
|
expect(repository.getUidForClass(Counter)).toBe(uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports whether a class is registered', () => {
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
|
||||||
|
expect(repository.hasClass(Counter)).toBe(false);
|
||||||
|
repository.registerClass(Counter);
|
||||||
|
expect(repository.hasClass(Counter)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when registering the same class twice', () => {
|
||||||
|
const repository = ServiceRepository.getInstance();
|
||||||
|
repository.registerClass(Counter);
|
||||||
|
|
||||||
|
expect(() => repository.registerClass(Counter)).toThrow(/already registered/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resolving a class that was never registered', () => {
|
||||||
|
expect(() => ServiceRepository.getInstance().resolveClass(Counter)).toThrow(/not registered/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resolving an unknown uid', () => {
|
||||||
|
expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow(
|
||||||
|
/No service registered/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts fresh after resetForTesting', () => {
|
||||||
|
ServiceRepository.getInstance().registerClass(Counter);
|
||||||
|
ServiceRepository.resetForTesting();
|
||||||
|
|
||||||
|
expect(ServiceRepository.getInstance().hasClass(Counter)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/common/service-registry/src/service-repository.ts
Normal file
115
packages/common/service-registry/src/service-repository.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import type { ServiceConstructor, ServiceUid } from './types';
|
||||||
|
|
||||||
|
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central singleton registry that holds the single shared instance of every
|
||||||
|
* service registered via {@link RegisterService}, keyed by a unique id
|
||||||
|
* generated at registration time. Consumers rarely call this class directly
|
||||||
|
* — {@link RegisterService} and {@link Service} are the intended public API
|
||||||
|
* — but it is exported for advanced use cases (manual registration, uid
|
||||||
|
* lookups, diagnostics).
|
||||||
|
*/
|
||||||
|
export class ServiceRepository {
|
||||||
|
private static instance: ServiceRepository | undefined;
|
||||||
|
|
||||||
|
private instancesByUid = new Map<ServiceUid, unknown>();
|
||||||
|
private uidsByConstructor = new WeakMap<ServiceConstructor, ServiceUid>();
|
||||||
|
|
||||||
|
/** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the single shared instance of the repository, creating it on first access.
|
||||||
|
* @returns the singleton {@link ServiceRepository}
|
||||||
|
*/
|
||||||
|
public static getInstance(): ServiceRepository {
|
||||||
|
if (ServiceRepository.instance === undefined) {
|
||||||
|
ServiceRepository.instance = new ServiceRepository();
|
||||||
|
}
|
||||||
|
return ServiceRepository.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discards the singleton instance so the next {@link ServiceRepository.getInstance}
|
||||||
|
* call starts from an empty registry. Intended for resetting state between
|
||||||
|
* test cases — application code should not call this.
|
||||||
|
*/
|
||||||
|
public static resetForTesting(): void {
|
||||||
|
ServiceRepository.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates `target` (its constructor must take no arguments) and
|
||||||
|
* registers the resulting instance under a freshly generated unique id.
|
||||||
|
* Called by {@link RegisterService} — most callers should use that
|
||||||
|
* decorator instead of calling this method directly.
|
||||||
|
* @param target the service class to instantiate and register
|
||||||
|
* @returns the uid assigned to this service
|
||||||
|
* @throws {Error} if `target` is already registered
|
||||||
|
*/
|
||||||
|
public registerClass<T>(target: ServiceConstructor<T>): ServiceUid {
|
||||||
|
if (this.uidsByConstructor.has(target)) {
|
||||||
|
throw new Error(`Service "${target.name}" is already registered.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = globalThis.crypto.randomUUID();
|
||||||
|
const instance = new target();
|
||||||
|
|
||||||
|
this.instancesByUid.set(uid, instance);
|
||||||
|
this.uidsByConstructor.set(target, uid);
|
||||||
|
|
||||||
|
console.debug(`${LOG_PREFIX} Registered service "${target.name}" (${uid}).`);
|
||||||
|
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the shared instance registered for `target`. Called by
|
||||||
|
* {@link Service} — most callers should use that decorator instead of
|
||||||
|
* calling this method directly.
|
||||||
|
* @param target the service class previously registered via {@link RegisterService}
|
||||||
|
* @returns the shared instance of `target`
|
||||||
|
* @throws {Error} if `target` was never registered
|
||||||
|
*/
|
||||||
|
public resolveClass<T>(target: ServiceConstructor<T>): T {
|
||||||
|
const uid = this.uidsByConstructor.get(target);
|
||||||
|
if (uid === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`Service "${target.name}" is not registered. Did you forget @RegisterService?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.resolve<T>(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a registered service instance directly by its uid.
|
||||||
|
* @param uid the service's unique identifier, as returned by {@link ServiceRepository.registerClass}
|
||||||
|
* @returns the instance registered under `uid`
|
||||||
|
* @throws {Error} if no service is registered under `uid`
|
||||||
|
*/
|
||||||
|
public resolve<T>(uid: ServiceUid): T {
|
||||||
|
if (!this.instancesByUid.has(uid)) {
|
||||||
|
throw new Error(`No service registered with uid "${uid}".`);
|
||||||
|
}
|
||||||
|
return this.instancesByUid.get(uid) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the uid assigned to a registered service class.
|
||||||
|
* @param target the service class to look up
|
||||||
|
* @returns the assigned uid, or `undefined` if `target` is not registered
|
||||||
|
*/
|
||||||
|
public getUidForClass(target: ServiceConstructor): ServiceUid | undefined {
|
||||||
|
return this.uidsByConstructor.get(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a service class is currently registered.
|
||||||
|
* @param target the service class to check
|
||||||
|
* @returns `true` if `target` has been registered
|
||||||
|
*/
|
||||||
|
public hasClass(target: ServiceConstructor): boolean {
|
||||||
|
return this.uidsByConstructor.has(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { RegisterService } from './register-service.decorator';
|
||||||
|
import { Service } from './service.decorator';
|
||||||
|
import { ServiceRepository } from './service-repository';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ServiceRepository.resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Service', () => {
|
||||||
|
it('injects the shared instance registered for the target class', () => {
|
||||||
|
@RegisterService()
|
||||||
|
class LoggerService {
|
||||||
|
public readonly messages: string[] = [];
|
||||||
|
|
||||||
|
public log(message: string): void {
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReportGenerator {
|
||||||
|
@Service(LoggerService)
|
||||||
|
public readonly logger!: LoggerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = new ReportGenerator();
|
||||||
|
generator.logger.log('hello');
|
||||||
|
|
||||||
|
expect(generator.logger).toBeInstanceOf(LoggerService);
|
||||||
|
expect(ServiceRepository.getInstance().resolveClass(LoggerService).messages).toEqual(['hello']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves lazily: decoration does not require the service to be registered yet', () => {
|
||||||
|
class LateService {
|
||||||
|
public readonly ok = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Consumer {
|
||||||
|
@Service(LateService)
|
||||||
|
public readonly svc!: LateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumer (and its @Service decoration) already ran above, yet LateService
|
||||||
|
// is only registered now — proves decoration itself never required it.
|
||||||
|
RegisterService()(LateService);
|
||||||
|
|
||||||
|
expect(new Consumer().svc.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memoizes the resolved value on the instance after the first access', () => {
|
||||||
|
@RegisterService()
|
||||||
|
class LoggerService {
|
||||||
|
public log(_message: string): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Consumer {
|
||||||
|
@Service(LoggerService)
|
||||||
|
public readonly logger!: LoggerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumer = new Consumer();
|
||||||
|
expect(Object.getOwnPropertyDescriptor(consumer, 'logger')).toBeUndefined();
|
||||||
|
|
||||||
|
void consumer.logger;
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(consumer, 'logger');
|
||||||
|
expect(descriptor?.get).toBeUndefined();
|
||||||
|
expect(descriptor?.value).toBeInstanceOf(LoggerService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the target service was never registered', () => {
|
||||||
|
class UnregisteredService {}
|
||||||
|
class Consumer {
|
||||||
|
@Service(UnregisteredService)
|
||||||
|
public readonly dep!: UnregisteredService;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => new Consumer().dep).toThrow(/not registered/);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
packages/common/service-registry/src/service.decorator.ts
Normal file
58
packages/common/service-registry/src/service.decorator.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ServiceRepository } from './service-repository';
|
||||||
|
import type { ServiceConstructor } from './types';
|
||||||
|
|
||||||
|
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property decorator that injects the shared instance of `target`
|
||||||
|
* (previously registered via {@link RegisterService}) into the decorated
|
||||||
|
* class member.
|
||||||
|
*
|
||||||
|
* Resolution is lazy and memoized: the service is looked up in the
|
||||||
|
* {@link ServiceRepository} the first time the property is read, not at
|
||||||
|
* decoration time — this avoids depending on module evaluation order
|
||||||
|
* between a service and its consumers. After the first read, the property
|
||||||
|
* is replaced by the resolved value on the instance, so later accesses skip
|
||||||
|
* the repository lookup entirely.
|
||||||
|
* @param target the service class to inject, previously registered with {@link RegisterService}
|
||||||
|
* @returns a property decorator
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* class ReportGenerator {
|
||||||
|
* @Service(LoggerService)
|
||||||
|
* private readonly logger!: LoggerService;
|
||||||
|
*
|
||||||
|
* public run(): void {
|
||||||
|
* this.logger.log('generating report');
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Service<T>(
|
||||||
|
target: ServiceConstructor<T>,
|
||||||
|
): (prototype: object, propertyKey: string | symbol) => void {
|
||||||
|
return (prototype: object, propertyKey: string | symbol): void => {
|
||||||
|
Object.defineProperty(prototype, propertyKey, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get(this: object): T {
|
||||||
|
let resolved: T;
|
||||||
|
try {
|
||||||
|
resolved = ServiceRepository.getInstance().resolveClass(target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`${LOG_PREFIX} Failed to inject service "${target.name}" into member "${String(propertyKey)}".`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
Object.defineProperty(this, propertyKey, {
|
||||||
|
value: resolved,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
14
packages/common/service-registry/src/types.ts
Normal file
14
packages/common/service-registry/src/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* A concrete class that {@link ServiceRepository} can instantiate on its own,
|
||||||
|
* i.e. one whose constructor takes no arguments. Services registered via
|
||||||
|
* {@link RegisterService} must satisfy this shape.
|
||||||
|
*/
|
||||||
|
export interface ServiceConstructor<T = unknown> {
|
||||||
|
new (): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique identifier assigned to a service when it is registered in the
|
||||||
|
* {@link ServiceRepository}.
|
||||||
|
*/
|
||||||
|
export type ServiceUid = string;
|
||||||
8
packages/common/service-registry/tsconfig.json
Normal file
8
packages/common/service-registry/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -123,7 +123,9 @@ writeFileSync(packagesSpecPath, packagesSpec.trimEnd() + '\n' + newRow);
|
||||||
console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`);
|
console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync('pnpm', ['install'], { cwd: ROOT, stdio: 'inherit' });
|
// shell: true est nécessaire sous Windows, où `pnpm` est un shim .cmd/.ps1
|
||||||
|
// que execFileSync ne résout pas via PATHEXT sans passer par un shell.
|
||||||
|
execFileSync('pnpm', ['install'], { cwd: ROOT, stdio: 'inherit', shell: true });
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'pnpm',
|
'pnpm',
|
||||||
[
|
[
|
||||||
|
|
@ -135,7 +137,7 @@ try {
|
||||||
'tsconfig.json',
|
'tsconfig.json',
|
||||||
'spec/packages.md',
|
'spec/packages.md',
|
||||||
],
|
],
|
||||||
{ cwd: ROOT, stdio: 'inherit' },
|
{ cwd: ROOT, stdio: 'inherit', shell: true },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Étape automatique échouée, à relancer toi-même :');
|
console.error('Étape automatique échouée, à relancer toi-même :');
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ Un nouveau package se crée avec `pnpm create-package <client|common|server> <no
|
||||||
| Versioning/publish | [Changesets](https://github.com/changesets/changesets) | Gère des versions indépendantes par package + changelogs, comprend nativement `workspace:*` |
|
| Versioning/publish | [Changesets](https://github.com/changesets/changesets) | Gère des versions indépendantes par package + changelogs, comprend nativement `workspace:*` |
|
||||||
| Node cible | 24 (Active LTS) en CI, `engines: ">=22"` | Node 20 est end-of-life depuis avril 2026 |
|
| Node cible | 24 (Active LTS) en CI, `engines: ">=22"` | Node 20 est end-of-life depuis avril 2026 |
|
||||||
|
|
||||||
|
## Décorateurs et `useDefineForClassFields`
|
||||||
|
|
||||||
|
`tsconfig.base.json` force `"useDefineForClassFields": false`, bien que la cible soit `ES2022` (où ce flag vaut `true` par défaut). Sans ça, un champ de classe déclaré sans initialiseur (`logger!: LoggerService;`) est réinitialisé à `undefined` **sur l'instance** au moment de la construction, ce qui masque silencieusement tout accesseur posé sur le prototype par un décorateur de propriété (`@Service` de `@lightweight-sdk/service-registry`, `@LinkToDom` de `web-component`). C'est une interaction connue entre `useDefineForClassFields` et les décorateurs legacy (`experimentalDecorators`) — désactiver ce flag restaure la sémantique classique où un champ sans initialiseur n'émet aucun code, laissant l'accesseur du prototype pleinement fonctionner.
|
||||||
|
|
||||||
## Dépendances entre packages
|
## Dépendances entre packages
|
||||||
|
|
||||||
Les dépendances internes entre packages du monorepo utilisent le protocole `workspace:*` (résolu localement par pnpm, jamais via le registre distant). Le registre npm privé n'est utilisé que pour la publication et pour la consommation depuis des projets *externes* à ce monorepo.
|
Les dépendances internes entre packages du monorepo utilisent le protocole `workspace:*` (résolu localement par pnpm, jamais via le registre distant). Le registre npm privé n'est utilisé que pour la publication et pour la consommation depuis des projets *externes* à ce monorepo.
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,4 @@ Le code source de ce package a été migré strictement à l'identique depuis l'
|
||||||
- Le style de formatage d'origine (4 espaces, guillemets doubles) diffère de la config Biome du monorepo (tabulations, guillemets simples) — le formatter Biome est désactivé pour ce package via le même `overrides`.
|
- Le style de formatage d'origine (4 espaces, guillemets doubles) diffère de la config Biome du monorepo (tabulations, guillemets simples) — le formatter Biome est désactivé pour ce package via le même `overrides`.
|
||||||
- `REQUEST_DOM_LINK_KEY` semble ne plus être utilisé nulle part dans le code (le mécanisme actif est le tableau `__linkedProperties`) — potentiellement mort, mais pas supprimé.
|
- `REQUEST_DOM_LINK_KEY` semble ne plus être utilisé nulle part dans le code (le mécanisme actif est le tableau `__linkedProperties`) — potentiellement mort, mais pas supprimé.
|
||||||
- Quelques typos mineures ("atatched", "attriute") et un commentaire en français dans `LinkToDom` qui ne correspond plus exactement au comportement (`throw` au lieu d'un simple avertissement).
|
- Quelques typos mineures ("atatched", "attriute") et un commentaire en français dans `LinkToDom` qui ne correspond plus exactement au comportement (`throw` au lieu d'un simple avertissement).
|
||||||
|
| `@lightweight-sdk/service-registry` | common | 0.0.1 | brouillon |
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"experimentalDecorators": true
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [{ "path": "./packages/client/web-component" }]
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./packages/client/web-component"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/common/service-registry"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue