From 10bc2090389c749f5dabace830eec6530d7b787f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 18 Sep 2026 13:59:58 +0200 Subject: [PATCH 1/4] Ajouter @lightweight-sdk/service-registry (packages/common) Registre de services en singleton avec injection par decorateurs : - ServiceRepository : singleton central, uid genere automatiquement (crypto.randomUUID) a l'enregistrement, une seule instance partagee par service. - @RegisterService() : decorateur de classe, instancie et enregistre automatiquement la classe decoree (constructeur sans argument). - @Service(target) : decorateur de propriete, injection paresseuse et memoisee par reference de classe (resolution au premier acces, pas a la decoration - insensible a l'ordre d'evaluation des modules). Fix connexe dans tsconfig.base.json : useDefineForClassFields desactive (target ES2022 l'active par defaut), sinon un champ de classe sans initialiseur est reinitialise a undefined sur l'instance a la construction et masque l'accesseur pose sur le prototype par un decorateur de propriete - touche @Service ici et @LinkToDom dans web-component. Documente dans spec/architecture.md. Fix connexe dans scripts/create-package.mjs : execFileSync('pnpm', ...) ne resout pas le shim .cmd de pnpm sous Windows sans shell: true (trouve en re-testant le script sur ce package). Co-Authored-By: Claude Sonnet 5 --- .changeset/add-service-registry-package.md | 5 + packages/common/service-registry/README.md | 49 ++++++++ packages/common/service-registry/package.json | 23 ++++ .../common/service-registry/src/index.test.ts | 24 ++++ packages/common/service-registry/src/index.ts | 4 + .../src/register-service.decorator.test.ts | 41 +++++++ .../src/register-service.decorator.ts | 33 +++++ .../src/service-repository.test.ts | 70 +++++++++++ .../src/service-repository.ts | 115 ++++++++++++++++++ .../src/service.decorator.test.ts | 80 ++++++++++++ .../service-registry/src/service.decorator.ts | 58 +++++++++ packages/common/service-registry/src/types.ts | 14 +++ .../common/service-registry/tsconfig.json | 8 ++ scripts/create-package.mjs | 6 +- spec/architecture.md | 4 + spec/packages.md | 1 + tsconfig.base.json | 3 +- tsconfig.json | 9 +- 18 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 .changeset/add-service-registry-package.md create mode 100644 packages/common/service-registry/README.md create mode 100644 packages/common/service-registry/package.json create mode 100644 packages/common/service-registry/src/index.test.ts create mode 100644 packages/common/service-registry/src/index.ts create mode 100644 packages/common/service-registry/src/register-service.decorator.test.ts create mode 100644 packages/common/service-registry/src/register-service.decorator.ts create mode 100644 packages/common/service-registry/src/service-repository.test.ts create mode 100644 packages/common/service-registry/src/service-repository.ts create mode 100644 packages/common/service-registry/src/service.decorator.test.ts create mode 100644 packages/common/service-registry/src/service.decorator.ts create mode 100644 packages/common/service-registry/src/types.ts create mode 100644 packages/common/service-registry/tsconfig.json diff --git a/.changeset/add-service-registry-package.md b/.changeset/add-service-registry-package.md new file mode 100644 index 0000000..5deed3b --- /dev/null +++ b/.changeset/add-service-registry-package.md @@ -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). diff --git a/packages/common/service-registry/README.md b/packages/common/service-registry/README.md new file mode 100644 index 0000000..99bc669 --- /dev/null +++ b/packages/common/service-registry/README.md @@ -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`, `ServiceUid` — types associés. diff --git a/packages/common/service-registry/package.json b/packages/common/service-registry/package.json new file mode 100644 index 0000000..9229ff2 --- /dev/null +++ b/packages/common/service-registry/package.json @@ -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" + } +} diff --git a/packages/common/service-registry/src/index.test.ts b/packages/common/service-registry/src/index.test.ts new file mode 100644 index 0000000..9b7cada --- /dev/null +++ b/packages/common/service-registry/src/index.test.ts @@ -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); + }); +}); diff --git a/packages/common/service-registry/src/index.ts b/packages/common/service-registry/src/index.ts new file mode 100644 index 0000000..17b5a85 --- /dev/null +++ b/packages/common/service-registry/src/index.ts @@ -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'; diff --git a/packages/common/service-registry/src/register-service.decorator.test.ts b/packages/common/service-registry/src/register-service.decorator.test.ts new file mode 100644 index 0000000..61476ee --- /dev/null +++ b/packages/common/service-registry/src/register-service.decorator.test.ts @@ -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/); + }); +}); diff --git a/packages/common/service-registry/src/register-service.decorator.ts b/packages/common/service-registry/src/register-service.decorator.ts new file mode 100644 index 0000000..ac6033a --- /dev/null +++ b/packages/common/service-registry/src/register-service.decorator.ts @@ -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(): (target: ServiceConstructor) => void { + return (target: ServiceConstructor): void => { + try { + ServiceRepository.getInstance().registerClass(target); + } catch (error) { + console.error(`${LOG_PREFIX} Failed to register service "${target.name}".`, error); + throw error; + } + }; +} diff --git a/packages/common/service-registry/src/service-repository.test.ts b/packages/common/service-registry/src/service-repository.test.ts new file mode 100644 index 0000000..914e2d3 --- /dev/null +++ b/packages/common/service-registry/src/service-repository.test.ts @@ -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); + }); +}); diff --git a/packages/common/service-registry/src/service-repository.ts b/packages/common/service-registry/src/service-repository.ts new file mode 100644 index 0000000..074e896 --- /dev/null +++ b/packages/common/service-registry/src/service-repository.ts @@ -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(); + private uidsByConstructor = new WeakMap(); + + /** 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(target: ServiceConstructor): 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(target: ServiceConstructor): 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(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(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); + } +} diff --git a/packages/common/service-registry/src/service.decorator.test.ts b/packages/common/service-registry/src/service.decorator.test.ts new file mode 100644 index 0000000..44a1465 --- /dev/null +++ b/packages/common/service-registry/src/service.decorator.test.ts @@ -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/); + }); +}); diff --git a/packages/common/service-registry/src/service.decorator.ts b/packages/common/service-registry/src/service.decorator.ts new file mode 100644 index 0000000..9c07567 --- /dev/null +++ b/packages/common/service-registry/src/service.decorator.ts @@ -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( + target: ServiceConstructor, +): (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; + }, + }); + }; +} diff --git a/packages/common/service-registry/src/types.ts b/packages/common/service-registry/src/types.ts new file mode 100644 index 0000000..9b747ef --- /dev/null +++ b/packages/common/service-registry/src/types.ts @@ -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 { + new (): T; +} + +/** + * Unique identifier assigned to a service when it is registered in the + * {@link ServiceRepository}. + */ +export type ServiceUid = string; diff --git a/packages/common/service-registry/tsconfig.json b/packages/common/service-registry/tsconfig.json new file mode 100644 index 0000000..2ab4a4c --- /dev/null +++ b/packages/common/service-registry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/scripts/create-package.mjs b/scripts/create-package.mjs index e69d7d9..d7555c0 100644 --- a/scripts/create-package.mjs +++ b/scripts/create-package.mjs @@ -123,7 +123,9 @@ writeFileSync(packagesSpecPath, packagesSpec.trimEnd() + '\n' + newRow); console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`); 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( 'pnpm', [ @@ -135,7 +137,7 @@ try { 'tsconfig.json', 'spec/packages.md', ], - { cwd: ROOT, stdio: 'inherit' }, + { cwd: ROOT, stdio: 'inherit', shell: true }, ); } catch (error) { console.error('Étape automatique échouée, à relancer toi-même :'); diff --git a/spec/architecture.md b/spec/architecture.md index 78bf6d4..4622fcf 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -30,6 +30,10 @@ Un nouveau package se crée avec `pnpm create-package =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 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. diff --git a/spec/packages.md b/spec/packages.md index b208143..278e2ec 100644 --- a/spec/packages.md +++ b/spec/packages.md @@ -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`. - `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). +| `@lightweight-sdk/service-registry` | common | 0.0.1 | brouillon | diff --git a/tsconfig.base.json b/tsconfig.base.json index a04f7f5..62ef3a1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "experimentalDecorators": true + "experimentalDecorators": true, + "useDefineForClassFields": false } } diff --git a/tsconfig.json b/tsconfig.json index 446a9fb..d752f19 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,11 @@ { "files": [], - "references": [{ "path": "./packages/client/web-component" }] + "references": [ + { + "path": "./packages/client/web-component" + }, + { + "path": "./packages/common/service-registry" + } + ] } From f0f57f170cc52dcb00c13b8c210818362b9f59df Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 18 Sep 2026 14:06:19 +0200 Subject: [PATCH 2/4] Corriger pnpm-lock.yaml : entree manquante pour service-registry pnpm 12.4.2 n'ecrivait pas d'entree importers pour packages/common/service-registry dans le lockfile, meme apres suppression complete du lockfile et du cache d'etat pnpm puis reinstallation (pnpm ls -r reconnaissait pourtant bien le package). CI echouait avec --frozen-lockfile (erreur has no importers[...] entry), alors que le pnpm install local sans --frozen-lockfile ne se plaignait de rien. Cause probable : ce package ne declarait aucune dependency ni devDependency propre. Remede : declarer explicitement quelques devDependencies (typescript, vitest, @types/node via catalog:), meme deja hoistees depuis la racine. create-package.mjs generera desormais ces devDependencies par defaut pour tout nouveau package, et le troubleshooting est documente dans spec/ci-cd.md. Co-Authored-By: Claude Sonnet 5 --- packages/common/service-registry/package.json | 5 +++++ pnpm-lock.yaml | 12 ++++++++++++ scripts/create-package.mjs | 9 +++++++++ spec/ci-cd.md | 4 ++++ 4 files changed, 30 insertions(+) diff --git a/packages/common/service-registry/package.json b/packages/common/service-registry/package.json index 9229ff2..4639f23 100644 --- a/packages/common/service-registry/package.json +++ b/packages/common/service-registry/package.json @@ -19,5 +19,10 @@ "src/index.ts" ], "readme": "README.md" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffb60f..ed2304f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,18 @@ importers: packages/client/web-component: {} + packages/common/service-registry: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@22.20.3) + packages: '@babel/runtime@7.29.7': diff --git a/scripts/create-package.mjs b/scripts/create-package.mjs index d7555c0..a3e7618 100644 --- a/scripts/create-package.mjs +++ b/scripts/create-package.mjs @@ -53,6 +53,15 @@ const packageJson = { entryPoints: ['src/index.ts'], readme: 'README.md', }, + // Déclarées explicitement (même si déjà présentes à la racine, où elles + // seraient hoistées) : un package workspace sans aucune dépendance propre + // peut se retrouver sans entrée dans pnpm-lock.yaml, ce qui casse + // `pnpm install --frozen-lockfile` en CI (voir spec/ci-cd.md). + devDependencies: { + '@types/node': 'catalog:', + typescript: 'catalog:', + vitest: 'catalog:', + }, }; writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify(packageJson, null, '\t')}\n`); diff --git a/spec/ci-cd.md b/spec/ci-cd.md index 81e7cab..ebaf326 100644 --- a/spec/ci-cd.md +++ b/spec/ci-cd.md @@ -35,6 +35,10 @@ Pas de bot automatique type `changesets/action` (spécifique GitHub, incertain s 1. Forgejo → Settings → Applications → générer un token avec les scopes `write:package` + `read:package`. 2. L'ajouter comme secret de repo nommé `REGISTRY_TOKEN` sur **ce** repo (`lightweight-sdk`) — les secrets de repo ne sont pas partagés entre repos sur cette instance ; celui de `watchparty` ne peut pas être réutilisé tel quel. +### Troubleshooting : `pnpm-lock.yaml` sans entrée pour un nouveau package + +Si `pnpm install --frozen-lockfile` échoue en CI avec `pnpm-lock.yaml has no importers["packages//"] entry` alors que `pnpm install` (sans `--frozen-lockfile`) fonctionne en local sans se plaindre : un package workspace **sans aucune `dependencies`/`devDependencies` propre** peut ne pas obtenir d'entrée dans le lockfile généré par pnpm 12.4.2, même après suppression complète du lockfile et réinstallation. Constaté en ajoutant `@lightweight-sdk/service-registry` (package.json sans champ de dépendances du tout). Remède : déclarer explicitement quelques `devDependencies` (ex. `typescript`, `vitest` via `catalog:`) même si elles seraient de toute façon hoistées depuis la racine — `create-package.mjs` le fait automatiquement pour tout nouveau package. Vérifier avant de pousser : `pnpm install --frozen-lockfile` en local doit passer, pas seulement `pnpm install`. + ### Troubleshooting : 404 sur un package scopé Si `pnpm changeset:publish` échoue avec une 404 sur un nom de package scopé (`@lightweight-sdk/...`), la cause probable est le reverse proxy devant l'instance : npm encode le `/` d'un nom scopé en `%2F`, ce qu'Apache refuse par défaut. Vérifier `AllowEncodedSlashes NoDecode` dans le VirtualHost et `nocanon` sur la directive `ProxyPass`. Ce point n'a pas pu être vérifié depuis l'agent (config réseau propre à l'instance). From 7d11dd1426e4f2898c466072b4acce03a26fab9c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 18 Sep 2026 14:22:06 +0200 Subject: [PATCH 3/4] uid fourni par le developpeur au lieu d'etre auto-genere @RegisterService(uid) et @Service(uid) prennent desormais l'uid en parametre explicite, choisi par l'appelant, plutot que de le generer automatiquement (crypto.randomUUID) et de resoudre par reference de classe. ServiceRepository simplifie en consequence : une seule map uid -> instance (plus de WeakMap constructeur -> uid, resolveClass/ getUidForClass/hasClass supprimes, devenus inutiles). @Service ne peut plus inferer le type depuis une reference de classe : a annoter la propriete ou a typer explicitement (@Service(uid)). Co-Authored-By: Claude Sonnet 5 --- packages/common/service-registry/README.md | 21 +++-- .../common/service-registry/src/index.test.ts | 4 +- .../src/register-service.decorator.test.ts | 23 ++--- .../src/register-service.decorator.ts | 24 ++--- .../src/service-repository.test.ts | 43 +++------ .../src/service-repository.ts | 87 ++++++------------- .../src/service.decorator.test.ts | 31 +++---- .../service-registry/src/service.decorator.ts | 20 +++-- 8 files changed, 109 insertions(+), 144 deletions(-) diff --git a/packages/common/service-registry/README.md b/packages/common/service-registry/README.md index 99bc669..a5a65e9 100644 --- a/packages/common/service-registry/README.md +++ b/packages/common/service-registry/README.md @@ -13,7 +13,9 @@ pnpm add @lightweight-sdk/service-registry ```ts import { RegisterService, Service } from '@lightweight-sdk/service-registry'; -@RegisterService() +export const LOGGER_UID = 'logger'; + +@RegisterService(LOGGER_UID) class LoggerService { public log(message: string): void { console.log(message); @@ -21,7 +23,7 @@ class LoggerService { } class ReportGenerator { - @Service(LoggerService) + @Service(LOGGER_UID) private readonly logger!: LoggerService; public run(): void { @@ -32,18 +34,19 @@ class ReportGenerator { ## 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). +- **`ServiceRepository`** — singleton central qui stocke une instance unique de chaque service, indexée par l'uid choisi par le développeur à l'enregistrement. Accès direct rarement nécessaire : `RegisterService`/`Service` couvrent l'usage courant. +- **`@RegisterService(uid)`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository` sous `uid`. **C'est au développeur de choisir `uid` et de garantir son unicité** — la registry refuse tout doublon. +- **`@Service(uid)`** — décorateur de propriété. Injecte l'instance enregistrée sous `uid` dans le membre décoré. `uid` seul ne porte pas le type : annoter la propriété (ou passer le paramètre générique ``) pour un typage correct. 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. +- Une classe décorée par `@RegisterService(uid)` 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. +- `uid` doit être unique dans toute l'application — exporter une constante partagée (comme `LOGGER_UID` ci-dessus) entre le point d'enregistrement et les points d'injection évite les fautes de frappe. - `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. +- `RegisterService(uid)` — décorateur de classe. +- `Service(uid)` — décorateur de propriété. +- `ServiceRepository` — singleton (`getInstance()`, `register(uid, target)`, `resolve(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests. - `ServiceConstructor`, `ServiceUid` — types associés. diff --git a/packages/common/service-registry/src/index.test.ts b/packages/common/service-registry/src/index.test.ts index 9b7cada..a1d155a 100644 --- a/packages/common/service-registry/src/index.test.ts +++ b/packages/common/service-registry/src/index.test.ts @@ -7,7 +7,7 @@ beforeEach(() => { describe('public API (barrel export)', () => { it('registers and injects a service end to end through the public exports', () => { - @RegisterService() + @RegisterService('clock') class ClockService { public now(): number { return 1234; @@ -15,7 +15,7 @@ describe('public API (barrel export)', () => { } class Consumer { - @Service(ClockService) + @Service('clock') public readonly clock!: ClockService; } diff --git a/packages/common/service-registry/src/register-service.decorator.test.ts b/packages/common/service-registry/src/register-service.decorator.test.ts index 61476ee..14a9912 100644 --- a/packages/common/service-registry/src/register-service.decorator.test.ts +++ b/packages/common/service-registry/src/register-service.decorator.test.ts @@ -7,8 +7,8 @@ beforeEach(() => { }); describe('RegisterService', () => { - it('registers the decorated class in the global repository', () => { - @RegisterService() + it('registers the decorated class under the given uid', () => { + @RegisterService('greeter') class GreeterService { public greet(name: string): string { return `Hello, ${name}!`; @@ -16,26 +16,27 @@ describe('RegisterService', () => { } const repository = ServiceRepository.getInstance(); - expect(repository.hasClass(GreeterService)).toBe(true); - expect(repository.resolveClass(GreeterService)).toBeInstanceOf(GreeterService); + expect(repository.has('greeter')).toBe(true); + expect(repository.resolve('greeter')).toBeInstanceOf(GreeterService); }); it('shares a single instance across every resolution', () => { - @RegisterService() + @RegisterService('state') class StateService { public value = 0; } const repository = ServiceRepository.getInstance(); - repository.resolveClass(StateService).value = 42; + repository.resolve('state').value = 42; - expect(repository.resolveClass(StateService).value).toBe(42); + expect(repository.resolve('state').value).toBe(42); }); - it('rethrows if the class is registered twice', () => { - class DuplicateService {} - RegisterService()(DuplicateService); + it('rethrows if the uid is already registered', () => { + class ServiceA {} + class ServiceB {} + RegisterService('duplicate')(ServiceA); - expect(() => RegisterService()(DuplicateService)).toThrow(/already registered/); + expect(() => RegisterService('duplicate')(ServiceB)).toThrow(/already registered/); }); }); diff --git a/packages/common/service-registry/src/register-service.decorator.ts b/packages/common/service-registry/src/register-service.decorator.ts index ac6033a..f528a7d 100644 --- a/packages/common/service-registry/src/register-service.decorator.ts +++ b/packages/common/service-registry/src/register-service.decorator.ts @@ -1,19 +1,23 @@ import { ServiceRepository } from './service-repository'; -import type { ServiceConstructor } from './types'; +import type { ServiceConstructor, ServiceUid } 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. + * resulting instance in the global {@link ServiceRepository} under `uid`, + * making it resolvable from anywhere via {@link Service}. The caller picks + * `uid` and is responsible for keeping it unique across the application. + * 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. + * @param uid the unique identifier this service will be registered and resolved under * @returns a class decorator * @example * ```ts - * ⁣@RegisterService() + * export const LOGGER_UID = 'logger'; + * + * ⁣@RegisterService(LOGGER_UID) * export class LoggerService { * public log(message: string): void { * console.log(message); @@ -21,12 +25,12 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]'; * } * ``` */ -export function RegisterService(): (target: ServiceConstructor) => void { +export function RegisterService(uid: ServiceUid): (target: ServiceConstructor) => void { return (target: ServiceConstructor): void => { try { - ServiceRepository.getInstance().registerClass(target); + ServiceRepository.getInstance().register(uid, target); } catch (error) { - console.error(`${LOG_PREFIX} Failed to register service "${target.name}".`, error); + console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error); throw error; } }; diff --git a/packages/common/service-registry/src/service-repository.test.ts b/packages/common/service-registry/src/service-repository.test.ts index 914e2d3..bd40de5 100644 --- a/packages/common/service-registry/src/service-repository.test.ts +++ b/packages/common/service-registry/src/service-repository.test.ts @@ -14,57 +14,42 @@ describe('ServiceRepository', () => { expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance()); }); - it('registers a class and resolves the same instance by class reference', () => { + it('registers a class and resolves the same instance by its uid', () => { const repository = ServiceRepository.getInstance(); - repository.registerClass(Counter); - const first = repository.resolveClass(Counter); - const second = repository.resolveClass(Counter); + repository.register('counter', Counter); + const first = repository.resolve('counter'); + const second = repository.resolve('counter'); expect(first).toBeInstanceOf(Counter); expect(first).toBe(second); }); - it('assigns a unique uid on registration and resolves the same instance by that uid', () => { + it('reports whether a uid is registered', () => { 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); + expect(repository.has('counter')).toBe(false); + repository.register('counter', Counter); + expect(repository.has('counter')).toBe(true); }); - it('reports whether a class is registered', () => { + it('throws when registering the same uid twice', () => { const repository = ServiceRepository.getInstance(); + repository.register('counter', Counter); - expect(repository.hasClass(Counter)).toBe(false); - repository.registerClass(Counter); - expect(repository.hasClass(Counter)).toBe(true); + expect(() => repository.register('counter', Counter)).toThrow(/already registered/); }); - 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', () => { + it('throws when resolving a uid that was never registered', () => { expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow( /No service registered/, ); }); it('starts fresh after resetForTesting', () => { - ServiceRepository.getInstance().registerClass(Counter); + ServiceRepository.getInstance().register('counter', Counter); ServiceRepository.resetForTesting(); - expect(ServiceRepository.getInstance().hasClass(Counter)).toBe(false); + expect(ServiceRepository.getInstance().has('counter')).toBe(false); }); }); diff --git a/packages/common/service-registry/src/service-repository.ts b/packages/common/service-registry/src/service-repository.ts index 074e896..ef77917 100644 --- a/packages/common/service-registry/src/service-repository.ts +++ b/packages/common/service-registry/src/service-repository.ts @@ -4,17 +4,16 @@ 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). + * service registered via {@link RegisterService}, keyed by the uid the + * developer assigned 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, diagnostics). */ export class ServiceRepository { private static instance: ServiceRepository | undefined; - private instancesByUid = new Map(); - private uidsByConstructor = new WeakMap(); + private instances = new Map(); /** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */ private constructor() {} @@ -41,75 +40,45 @@ export class ServiceRepository { /** * 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. + * registers the resulting instance under `uid`. Called by + * {@link RegisterService} — most callers should use that decorator + * instead of calling this method directly. + * @param uid the unique identifier the caller assigns to this service * @param target the service class to instantiate and register - * @returns the uid assigned to this service - * @throws {Error} if `target` is already registered + * @throws {Error} if `uid` is already registered */ - public registerClass(target: ServiceConstructor): ServiceUid { - if (this.uidsByConstructor.has(target)) { - throw new Error(`Service "${target.name}" is already registered.`); + public register(uid: ServiceUid, target: ServiceConstructor): void { + if (this.instances.has(uid)) { + throw new Error(`Service uid "${uid}" is already registered.`); } - const uid = globalThis.crypto.randomUUID(); const instance = new target(); + this.instances.set(uid, instance); - this.instancesByUid.set(uid, instance); - this.uidsByConstructor.set(target, uid); - - console.debug(`${LOG_PREFIX} Registered service "${target.name}" (${uid}).`); - - return uid; + console.debug(`${LOG_PREFIX} Registered service "${uid}" (${target.name}).`); } /** - * 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(target: ServiceConstructor): 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(uid); - } - - /** - * Resolves a registered service instance directly by its uid. - * @param uid the service's unique identifier, as returned by {@link ServiceRepository.registerClass} + * Resolves the instance registered under `uid`. Called by {@link Service} + * — most callers should use that decorator instead of calling this + * method directly. + * @param uid the service's unique identifier, as passed to {@link RegisterService} * @returns the instance registered under `uid` * @throws {Error} if no service is registered under `uid` */ public resolve(uid: ServiceUid): T { - if (!this.instancesByUid.has(uid)) { - throw new Error(`No service registered with uid "${uid}".`); + if (!this.instances.has(uid)) { + throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`); } - return this.instancesByUid.get(uid) as T; + return this.instances.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 + * Checks whether a service is currently registered under `uid`. + * @param uid the unique identifier to check + * @returns `true` if a service is registered under `uid` */ - 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); + public has(uid: ServiceUid): boolean { + return this.instances.has(uid); } } diff --git a/packages/common/service-registry/src/service.decorator.test.ts b/packages/common/service-registry/src/service.decorator.test.ts index 44a1465..5ae0b56 100644 --- a/packages/common/service-registry/src/service.decorator.test.ts +++ b/packages/common/service-registry/src/service.decorator.test.ts @@ -8,8 +8,8 @@ beforeEach(() => { }); describe('Service', () => { - it('injects the shared instance registered for the target class', () => { - @RegisterService() + it('injects the instance registered under the given uid', () => { + @RegisterService('logger') class LoggerService { public readonly messages: string[] = []; @@ -19,7 +19,7 @@ describe('Service', () => { } class ReportGenerator { - @Service(LoggerService) + @Service('logger') public readonly logger!: LoggerService; } @@ -27,34 +27,36 @@ describe('Service', () => { generator.logger.log('hello'); expect(generator.logger).toBeInstanceOf(LoggerService); - expect(ServiceRepository.getInstance().resolveClass(LoggerService).messages).toEqual(['hello']); + expect(ServiceRepository.getInstance().resolve('logger').messages).toEqual([ + 'hello', + ]); }); - it('resolves lazily: decoration does not require the service to be registered yet', () => { + it('resolves lazily: decoration does not require the uid to be registered yet', () => { class LateService { public readonly ok = true; } class Consumer { - @Service(LateService) + @Service('late') public readonly svc!: LateService; } - // Consumer (and its @Service decoration) already ran above, yet LateService + // Consumer (and its @Service decoration) already ran above, yet "late" // is only registered now — proves decoration itself never required it. - RegisterService()(LateService); + RegisterService('late')(LateService); expect(new Consumer().svc.ok).toBe(true); }); it('memoizes the resolved value on the instance after the first access', () => { - @RegisterService() + @RegisterService('logger') class LoggerService { public log(_message: string): void {} } class Consumer { - @Service(LoggerService) + @Service('logger') public readonly logger!: LoggerService; } @@ -68,13 +70,12 @@ describe('Service', () => { expect(descriptor?.value).toBeInstanceOf(LoggerService); }); - it('throws when the target service was never registered', () => { - class UnregisteredService {} + it('throws when the uid was never registered', () => { class Consumer { - @Service(UnregisteredService) - public readonly dep!: UnregisteredService; + @Service('unregistered') + public readonly dep!: unknown; } - expect(() => new Consumer().dep).toThrow(/not registered/); + expect(() => new Consumer().dep).toThrow(/No service registered/); }); }); diff --git a/packages/common/service-registry/src/service.decorator.ts b/packages/common/service-registry/src/service.decorator.ts index 9c07567..458d9a1 100644 --- a/packages/common/service-registry/src/service.decorator.ts +++ b/packages/common/service-registry/src/service.decorator.ts @@ -1,12 +1,11 @@ import { ServiceRepository } from './service-repository'; -import type { ServiceConstructor } from './types'; +import type { ServiceUid } 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. + * Property decorator that injects the instance registered under `uid` + * (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 @@ -14,12 +13,15 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]'; * 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} + * + * `uid` alone does not carry the service's type — annotate the property (or + * pass an explicit type argument) so the member has the right type. + * @param uid the unique identifier passed to {@link RegisterService} for the target service * @returns a property decorator * @example * ```ts * class ReportGenerator { - * ⁣@Service(LoggerService) + * ⁣@Service(LOGGER_UID) * private readonly logger!: LoggerService; * * public run(): void { @@ -29,7 +31,7 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]'; * ``` */ export function Service( - target: ServiceConstructor, + uid: ServiceUid, ): (prototype: object, propertyKey: string | symbol) => void { return (prototype: object, propertyKey: string | symbol): void => { Object.defineProperty(prototype, propertyKey, { @@ -38,10 +40,10 @@ export function Service( get(this: object): T { let resolved: T; try { - resolved = ServiceRepository.getInstance().resolveClass(target); + resolved = ServiceRepository.getInstance().resolve(uid); } catch (error) { console.error( - `${LOG_PREFIX} Failed to inject service "${target.name}" into member "${String(propertyKey)}".`, + `${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`, error, ); throw error; From 544614add722ff3a76bba15092dac009e095a2e1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 18 Sep 2026 14:31:42 +0200 Subject: [PATCH 4/4] Simplifier : retirer le generique de @Service, ajouter services - @Service(uid) n'a plus de parametre generique : un decorateur de propriete legacy ne peut de toute facon pas changer le type du champ qu'il decore, seule son annotation compte. Le generique n'apportait rien. - Nouvelle constante exportee services = ServiceRepository.getInstance(), instance prete a l'emploi : services.get(uid), services.has(uid), services.register(uid, Classe). C'est desormais le point d'entree recommande plutot que ServiceRepository.getInstance() directement. - ServiceRepository.resolve() renomme en get() pour matcher services.get(uid). - resetForTesting() vide la map en place (Map.clear()) au lieu de jeter l'instance singleton, pour que la constante services capturee au chargement du module reste valide apres un reset dans les tests. Co-Authored-By: Claude Sonnet 5 --- packages/common/service-registry/README.md | 25 +++++++---- .../common/service-registry/src/index.test.ts | 15 ++++++- packages/common/service-registry/src/index.ts | 2 +- .../src/register-service.decorator.test.ts | 12 +++--- .../src/register-service.decorator.ts | 8 ++-- .../src/service-repository.test.ts | 37 ++++++++--------- .../src/service-repository.ts | 41 +++++++++++++------ .../src/service.decorator.test.ts | 12 +++--- .../service-registry/src/service.decorator.ts | 33 +++++++-------- 9 files changed, 105 insertions(+), 80 deletions(-) diff --git a/packages/common/service-registry/README.md b/packages/common/service-registry/README.md index a5a65e9..db9d799 100644 --- a/packages/common/service-registry/README.md +++ b/packages/common/service-registry/README.md @@ -1,6 +1,6 @@ # @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. +Registre de services en singleton, avec enregistrement par décorateur et résolution directe. Package `common` : isomorphe, aucune dépendance DOM ou Node-only. ## Installation @@ -11,7 +11,7 @@ pnpm add @lightweight-sdk/service-registry ## Usage ```ts -import { RegisterService, Service } from '@lightweight-sdk/service-registry'; +import { RegisterService, Service, services } from '@lightweight-sdk/service-registry'; export const LOGGER_UID = 'logger'; @@ -22,31 +22,38 @@ class LoggerService { } } +// Injection déclarative sur une propriété class ReportGenerator { - @Service(LOGGER_UID) + @Service(LOGGER_UID) private readonly logger!: LoggerService; public run(): void { this.logger.log('generating report'); } } + +// Résolution directe, n'importe où +const logger = services.get(LOGGER_UID); +logger.log('hello'); ``` ## Concepts -- **`ServiceRepository`** — singleton central qui stocke une instance unique de chaque service, indexée par l'uid choisi par le développeur à l'enregistrement. Accès direct rarement nécessaire : `RegisterService`/`Service` couvrent l'usage courant. -- **`@RegisterService(uid)`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository` sous `uid`. **C'est au développeur de choisir `uid` et de garantir son unicité** — la registry refuse tout doublon. -- **`@Service(uid)`** — décorateur de propriété. Injecte l'instance enregistrée sous `uid` dans le membre décoré. `uid` seul ne porte pas le type : annoter la propriété (ou passer le paramètre générique ``) pour un typage correct. 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). +- **`services`** — constante globale, instance prête à l'emploi du `ServiceRepository`. C'est le point d'entrée à utiliser partout : `services.get(uid)`, `services.has(uid)`, `services.register(uid, MaClasse)`. +- **`ServiceRepository`** — la classe singleton derrière `services` (accès direct rarement nécessaire, exportée pour le typage et les tests). +- **`@RegisterService(uid)`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre sous `uid`. **C'est au développeur de choisir `uid` et de garantir son unicité** — la registry refuse tout doublon. +- **`@Service(uid)`** — décorateur de propriété. Injecte l'instance enregistrée sous `uid` dans le membre décoré. Pas de typage générique sur le décorateur : un décorateur de propriété ne peut pas changer le type du champ qu'il décore, donc c'est l'annotation du champ (`private readonly logger!: LoggerService;`) qui le type, comme d'habitude. 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(uid)` 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. +- Une classe décorée par `@RegisterService(uid)` doit avoir un constructeur sans argument obligatoire — ses propres dépendances doivent elles-mêmes être injectées via `@Service` ou `services.get`, pas passées au constructeur. - `uid` doit être unique dans toute l'application — exporter une constante partagée (comme `LOGGER_UID` ci-dessus) entre le point d'enregistrement et les points d'injection évite les fautes de frappe. - `experimentalDecorators: true` doit être activé côté consommateur (déjà le cas dans ce monorepo via `tsconfig.base.json`). ## API +- `services` — instance singleton prête à l'emploi (`get(uid)`, `has(uid)`, `register(uid, target)`). - `RegisterService(uid)` — décorateur de classe. -- `Service(uid)` — décorateur de propriété. -- `ServiceRepository` — singleton (`getInstance()`, `register(uid, target)`, `resolve(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests. +- `Service(uid)` — décorateur de propriété. +- `ServiceRepository` — la classe singleton (`getInstance()`, `register(uid, target)`, `get(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests. - `ServiceConstructor`, `ServiceUid` — types associés. diff --git a/packages/common/service-registry/src/index.test.ts b/packages/common/service-registry/src/index.test.ts index a1d155a..3f27261 100644 --- a/packages/common/service-registry/src/index.test.ts +++ b/packages/common/service-registry/src/index.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { RegisterService, Service, ServiceRepository } from './index'; +import { RegisterService, Service, ServiceRepository, services } from './index'; beforeEach(() => { ServiceRepository.resetForTesting(); @@ -15,10 +15,21 @@ describe('public API (barrel export)', () => { } class Consumer { - @Service('clock') + @Service('clock') public readonly clock!: ClockService; } expect(new Consumer().clock.now()).toBe(1234); }); + + it('resolves a service directly through the services constant', () => { + @RegisterService('clock') + class ClockService { + public now(): number { + return 5678; + } + } + + expect(services.get('clock').now()).toBe(5678); + }); }); diff --git a/packages/common/service-registry/src/index.ts b/packages/common/service-registry/src/index.ts index 17b5a85..d4a4569 100644 --- a/packages/common/service-registry/src/index.ts +++ b/packages/common/service-registry/src/index.ts @@ -1,4 +1,4 @@ export { RegisterService } from './register-service.decorator'; export { Service } from './service.decorator'; -export { ServiceRepository } from './service-repository'; +export { ServiceRepository, services } from './service-repository'; export { ServiceConstructor, ServiceUid } from './types'; diff --git a/packages/common/service-registry/src/register-service.decorator.test.ts b/packages/common/service-registry/src/register-service.decorator.test.ts index 14a9912..0383568 100644 --- a/packages/common/service-registry/src/register-service.decorator.test.ts +++ b/packages/common/service-registry/src/register-service.decorator.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { RegisterService } from './register-service.decorator'; -import { ServiceRepository } from './service-repository'; +import { ServiceRepository, services } from './service-repository'; beforeEach(() => { ServiceRepository.resetForTesting(); @@ -15,9 +15,8 @@ describe('RegisterService', () => { } } - const repository = ServiceRepository.getInstance(); - expect(repository.has('greeter')).toBe(true); - expect(repository.resolve('greeter')).toBeInstanceOf(GreeterService); + expect(services.has('greeter')).toBe(true); + expect(services.get('greeter')).toBeInstanceOf(GreeterService); }); it('shares a single instance across every resolution', () => { @@ -26,10 +25,9 @@ describe('RegisterService', () => { public value = 0; } - const repository = ServiceRepository.getInstance(); - repository.resolve('state').value = 42; + services.get('state').value = 42; - expect(repository.resolve('state').value).toBe(42); + expect(services.get('state').value).toBe(42); }); it('rethrows if the uid is already registered', () => { diff --git a/packages/common/service-registry/src/register-service.decorator.ts b/packages/common/service-registry/src/register-service.decorator.ts index f528a7d..92e13c4 100644 --- a/packages/common/service-registry/src/register-service.decorator.ts +++ b/packages/common/service-registry/src/register-service.decorator.ts @@ -1,12 +1,12 @@ -import { ServiceRepository } from './service-repository'; +import { services } from './service-repository'; import type { ServiceConstructor, ServiceUid } 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} under `uid`, - * making it resolvable from anywhere via {@link Service}. The caller picks + * resulting instance in {@link services} under `uid`, making it resolvable + * from anywhere via `services.get(uid)` or {@link Service}. The caller picks * `uid` and is responsible for keeping it unique across the application. * The decorated class must have a no-argument constructor — any * dependencies it needs should themselves be injected with {@link Service} @@ -28,7 +28,7 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]'; export function RegisterService(uid: ServiceUid): (target: ServiceConstructor) => void { return (target: ServiceConstructor): void => { try { - ServiceRepository.getInstance().register(uid, target); + services.register(uid, target); } catch (error) { console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error); throw error; diff --git a/packages/common/service-registry/src/service-repository.test.ts b/packages/common/service-registry/src/service-repository.test.ts index bd40de5..b7ede57 100644 --- a/packages/common/service-registry/src/service-repository.test.ts +++ b/packages/common/service-registry/src/service-repository.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ServiceRepository } from './service-repository'; +import { ServiceRepository, services } from './service-repository'; class Counter { public value = 0; @@ -10,46 +10,41 @@ beforeEach(() => { }); describe('ServiceRepository', () => { - it('returns the same instance on every call to getInstance', () => { + it('exposes a single instance via getInstance, matching the services constant', () => { expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance()); + expect(ServiceRepository.getInstance()).toBe(services); }); it('registers a class and resolves the same instance by its uid', () => { - const repository = ServiceRepository.getInstance(); - - repository.register('counter', Counter); - const first = repository.resolve('counter'); - const second = repository.resolve('counter'); + services.register('counter', Counter); + const first = services.get('counter'); + const second = services.get('counter'); expect(first).toBeInstanceOf(Counter); expect(first).toBe(second); }); it('reports whether a uid is registered', () => { - const repository = ServiceRepository.getInstance(); - - expect(repository.has('counter')).toBe(false); - repository.register('counter', Counter); - expect(repository.has('counter')).toBe(true); + expect(services.has('counter')).toBe(false); + services.register('counter', Counter); + expect(services.has('counter')).toBe(true); }); it('throws when registering the same uid twice', () => { - const repository = ServiceRepository.getInstance(); - repository.register('counter', Counter); + services.register('counter', Counter); - expect(() => repository.register('counter', Counter)).toThrow(/already registered/); + expect(() => services.register('counter', Counter)).toThrow(/already registered/); }); it('throws when resolving a uid that was never registered', () => { - expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow( - /No service registered/, - ); + expect(() => services.get('does-not-exist')).toThrow(/No service registered/); }); - it('starts fresh after resetForTesting', () => { - ServiceRepository.getInstance().register('counter', Counter); + it('clears registered services after resetForTesting, without discarding the singleton', () => { + services.register('counter', Counter); ServiceRepository.resetForTesting(); - expect(ServiceRepository.getInstance().has('counter')).toBe(false); + expect(services.has('counter')).toBe(false); + expect(ServiceRepository.getInstance()).toBe(services); }); }); diff --git a/packages/common/service-registry/src/service-repository.ts b/packages/common/service-registry/src/service-repository.ts index ef77917..7f8574a 100644 --- a/packages/common/service-registry/src/service-repository.ts +++ b/packages/common/service-registry/src/service-repository.ts @@ -5,17 +5,16 @@ 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 the uid the - * developer assigned 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, diagnostics). + * developer assigned at registration time. Prefer the ready-to-use + * {@link services} constant over calling {@link ServiceRepository.getInstance} + * directly. */ export class ServiceRepository { private static instance: ServiceRepository | undefined; private instances = new Map(); - /** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */ + /** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance} or {@link services}. */ private constructor() {} /** @@ -30,12 +29,13 @@ export class ServiceRepository { } /** - * 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. + * Clears every registered service. The singleton instance itself is kept + * (not discarded), so the {@link services} constant stays valid across + * resets. Intended for resetting state between test cases — application + * code should not call this. */ public static resetForTesting(): void { - ServiceRepository.instance = undefined; + ServiceRepository.getInstance().instances.clear(); } /** @@ -59,14 +59,16 @@ export class ServiceRepository { } /** - * Resolves the instance registered under `uid`. Called by {@link Service} - * — most callers should use that decorator instead of calling this - * method directly. + * Resolves the instance registered under `uid`. * @param uid the service's unique identifier, as passed to {@link RegisterService} * @returns the instance registered under `uid` * @throws {Error} if no service is registered under `uid` + * @example + * ```ts + * const logger = services.get(LOGGER_UID); + * ``` */ - public resolve(uid: ServiceUid): T { + public get(uid: ServiceUid): T { if (!this.instances.has(uid)) { throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`); } @@ -82,3 +84,16 @@ export class ServiceRepository { return this.instances.has(uid); } } + +/** + * Ready-to-use singleton instance of {@link ServiceRepository}. Import this + * wherever a service needs to be registered or resolved, instead of calling + * {@link ServiceRepository.getInstance} directly. + * @example + * ```ts + * import { services } from '@lightweight-sdk/service-registry'; + * + * const logger = services.get(LOGGER_UID); + * ``` + */ +export const services = ServiceRepository.getInstance(); diff --git a/packages/common/service-registry/src/service.decorator.test.ts b/packages/common/service-registry/src/service.decorator.test.ts index 5ae0b56..ff1561f 100644 --- a/packages/common/service-registry/src/service.decorator.test.ts +++ b/packages/common/service-registry/src/service.decorator.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { RegisterService } from './register-service.decorator'; import { Service } from './service.decorator'; -import { ServiceRepository } from './service-repository'; +import { ServiceRepository, services } from './service-repository'; beforeEach(() => { ServiceRepository.resetForTesting(); @@ -19,7 +19,7 @@ describe('Service', () => { } class ReportGenerator { - @Service('logger') + @Service('logger') public readonly logger!: LoggerService; } @@ -27,9 +27,7 @@ describe('Service', () => { generator.logger.log('hello'); expect(generator.logger).toBeInstanceOf(LoggerService); - expect(ServiceRepository.getInstance().resolve('logger').messages).toEqual([ - 'hello', - ]); + expect(services.get('logger').messages).toEqual(['hello']); }); it('resolves lazily: decoration does not require the uid to be registered yet', () => { @@ -38,7 +36,7 @@ describe('Service', () => { } class Consumer { - @Service('late') + @Service('late') public readonly svc!: LateService; } @@ -56,7 +54,7 @@ describe('Service', () => { } class Consumer { - @Service('logger') + @Service('logger') public readonly logger!: LoggerService; } diff --git a/packages/common/service-registry/src/service.decorator.ts b/packages/common/service-registry/src/service.decorator.ts index 458d9a1..1a2afbf 100644 --- a/packages/common/service-registry/src/service.decorator.ts +++ b/packages/common/service-registry/src/service.decorator.ts @@ -1,27 +1,28 @@ -import { ServiceRepository } from './service-repository'; +import { services } from './service-repository'; import type { ServiceUid } from './types'; const LOG_PREFIX = '[lightweight-sdk:service-registry]'; /** * Property decorator that injects the instance registered under `uid` - * (via {@link RegisterService}) into the decorated class member. + * (via {@link RegisterService}) into the decorated class member. No generic + * type parameter is needed here: a legacy property decorator cannot change + * the type of the field it decorates, so the field's own type annotation + * (`private readonly logger!: LoggerService;`) is what types the member — + * annotate it as usual. * - * 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. - * - * `uid` alone does not carry the service's type — annotate the property (or - * pass an explicit type argument) so the member has the right type. + * Resolution is lazy and memoized: the service is looked up in + * {@link services} 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 uid the unique identifier passed to {@link RegisterService} for the target service * @returns a property decorator * @example * ```ts * class ReportGenerator { - * ⁣@Service(LOGGER_UID) + * ⁣@Service(LOGGER_UID) * private readonly logger!: LoggerService; * * public run(): void { @@ -30,17 +31,17 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]'; * } * ``` */ -export function Service( +export function Service( uid: ServiceUid, ): (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; + get(this: object): unknown { + let resolved: unknown; try { - resolved = ServiceRepository.getInstance().resolve(uid); + resolved = services.get(uid); } catch (error) { console.error( `${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`,