Compare commits

..

No commits in common. "38aa956fde3872bc9f52e4239198c4b13acdbf9a" and "cb8fbb4b3b009420e7c9b113466d9f0611ef2e6c" have entirely different histories.

20 changed files with 4 additions and 563 deletions

View file

@ -1,5 +0,0 @@
---
"@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).

View file

@ -1,59 +0,0 @@
# @lightweight-sdk/service-registry
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
```bash
pnpm add @lightweight-sdk/service-registry
```
## Usage
```ts
import { RegisterService, Service, services } from '@lightweight-sdk/service-registry';
export const LOGGER_UID = 'logger';
@RegisterService(LOGGER_UID)
class LoggerService {
public log(message: string): void {
console.log(message);
}
}
// Injection déclarative sur une propriété
class ReportGenerator {
@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<LoggerService>(LOGGER_UID);
logger.log('hello');
```
## Concepts
- **`services`** — constante globale, instance prête à l'emploi du `ServiceRepository`. C'est le point d'entrée à utiliser partout : `services.get<T>(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` 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<T>(uid)`, `has(uid)`, `register(uid, target)`).
- `RegisterService(uid)` — décorateur de classe.
- `Service(uid)` — décorateur de propriété.
- `ServiceRepository` — la classe singleton (`getInstance()`, `register(uid, target)`, `get<T>(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests.
- `ServiceConstructor<T>`, `ServiceUid` — types associés.

View file

@ -1,28 +0,0 @@
{
"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"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View file

@ -1,35 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService, Service, ServiceRepository, services } from './index';
beforeEach(() => {
ServiceRepository.resetForTesting();
});
describe('public API (barrel export)', () => {
it('registers and injects a service end to end through the public exports', () => {
@RegisterService('clock')
class ClockService {
public now(): number {
return 1234;
}
}
class Consumer {
@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<ClockService>('clock').now()).toBe(5678);
});
});

View file

@ -1,4 +0,0 @@
export { RegisterService } from './register-service.decorator';
export { Service } from './service.decorator';
export { ServiceRepository, services } from './service-repository';
export { ServiceConstructor, ServiceUid } from './types';

View file

@ -1,40 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService } from './register-service.decorator';
import { ServiceRepository, services } from './service-repository';
beforeEach(() => {
ServiceRepository.resetForTesting();
});
describe('RegisterService', () => {
it('registers the decorated class under the given uid', () => {
@RegisterService('greeter')
class GreeterService {
public greet(name: string): string {
return `Hello, ${name}!`;
}
}
expect(services.has('greeter')).toBe(true);
expect(services.get<GreeterService>('greeter')).toBeInstanceOf(GreeterService);
});
it('shares a single instance across every resolution', () => {
@RegisterService('state')
class StateService {
public value = 0;
}
services.get<StateService>('state').value = 42;
expect(services.get<StateService>('state').value).toBe(42);
});
it('rethrows if the uid is already registered', () => {
class ServiceA {}
class ServiceB {}
RegisterService('duplicate')(ServiceA);
expect(() => RegisterService('duplicate')(ServiceB)).toThrow(/already registered/);
});
});

View file

@ -1,37 +0,0 @@
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 {@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}
* 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
* export const LOGGER_UID = 'logger';
*
* ⁣@RegisterService(LOGGER_UID)
* export class LoggerService {
* public log(message: string): void {
* console.log(message);
* }
* }
* ```
*/
export function RegisterService(uid: ServiceUid): <T>(target: ServiceConstructor<T>) => void {
return <T>(target: ServiceConstructor<T>): void => {
try {
services.register(uid, target);
} catch (error) {
console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error);
throw error;
}
};
}

View file

@ -1,50 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { ServiceRepository, services } from './service-repository';
class Counter {
public value = 0;
}
beforeEach(() => {
ServiceRepository.resetForTesting();
});
describe('ServiceRepository', () => {
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', () => {
services.register('counter', Counter);
const first = services.get<Counter>('counter');
const second = services.get<Counter>('counter');
expect(first).toBeInstanceOf(Counter);
expect(first).toBe(second);
});
it('reports whether a uid is registered', () => {
expect(services.has('counter')).toBe(false);
services.register('counter', Counter);
expect(services.has('counter')).toBe(true);
});
it('throws when registering the same uid twice', () => {
services.register('counter', Counter);
expect(() => services.register('counter', Counter)).toThrow(/already registered/);
});
it('throws when resolving a uid that was never registered', () => {
expect(() => services.get('does-not-exist')).toThrow(/No service registered/);
});
it('clears registered services after resetForTesting, without discarding the singleton', () => {
services.register('counter', Counter);
ServiceRepository.resetForTesting();
expect(services.has('counter')).toBe(false);
expect(ServiceRepository.getInstance()).toBe(services);
});
});

View file

@ -1,99 +0,0 @@
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 the uid the
* 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<ServiceUid, unknown>();
/** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance} or {@link services}. */
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;
}
/**
* 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.getInstance().instances.clear();
}
/**
* Instantiates `target` (its constructor must take no arguments) and
* 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
* @throws {Error} if `uid` is already registered
*/
public register<T>(uid: ServiceUid, target: ServiceConstructor<T>): void {
if (this.instances.has(uid)) {
throw new Error(`Service uid "${uid}" is already registered.`);
}
const instance = new target();
this.instances.set(uid, instance);
console.debug(`${LOG_PREFIX} Registered service "${uid}" (${target.name}).`);
}
/**
* 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<LoggerService>(LOGGER_UID);
* ```
*/
public get<T>(uid: ServiceUid): T {
if (!this.instances.has(uid)) {
throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`);
}
return this.instances.get(uid) as T;
}
/**
* 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 has(uid: ServiceUid): boolean {
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<LoggerService>(LOGGER_UID);
* ```
*/
export const services = ServiceRepository.getInstance();

View file

@ -1,79 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService } from './register-service.decorator';
import { Service } from './service.decorator';
import { ServiceRepository, services } from './service-repository';
beforeEach(() => {
ServiceRepository.resetForTesting();
});
describe('Service', () => {
it('injects the instance registered under the given uid', () => {
@RegisterService('logger')
class LoggerService {
public readonly messages: string[] = [];
public log(message: string): void {
this.messages.push(message);
}
}
class ReportGenerator {
@Service('logger')
public readonly logger!: LoggerService;
}
const generator = new ReportGenerator();
generator.logger.log('hello');
expect(generator.logger).toBeInstanceOf(LoggerService);
expect(services.get<LoggerService>('logger').messages).toEqual(['hello']);
});
it('resolves lazily: decoration does not require the uid to be registered yet', () => {
class LateService {
public readonly ok = true;
}
class Consumer {
@Service('late')
public readonly svc!: LateService;
}
// Consumer (and its @Service decoration) already ran above, yet "late"
// is only registered now — proves decoration itself never required it.
RegisterService('late')(LateService);
expect(new Consumer().svc.ok).toBe(true);
});
it('memoizes the resolved value on the instance after the first access', () => {
@RegisterService('logger')
class LoggerService {
public log(_message: string): void {}
}
class Consumer {
@Service('logger')
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 uid was never registered', () => {
class Consumer {
@Service('unregistered')
public readonly dep!: unknown;
}
expect(() => new Consumer().dep).toThrow(/No service registered/);
});
});

View file

@ -1,61 +0,0 @@
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. 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
* {@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)
* private readonly logger!: LoggerService;
*
* public run(): void {
* this.logger.log('generating report');
* }
* }
* ```
*/
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): unknown {
let resolved: unknown;
try {
resolved = services.get(uid);
} catch (error) {
console.error(
`${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`,
error,
);
throw error;
}
Object.defineProperty(this, propertyKey, {
value: resolved,
enumerable: true,
configurable: true,
});
return resolved;
},
});
};
}

View file

@ -1,14 +0,0 @@
/**
* 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;

View file

@ -1,8 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View file

@ -211,18 +211,6 @@ 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':

View file

@ -53,15 +53,6 @@ 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`);
@ -132,9 +123,7 @@ writeFileSync(packagesSpecPath, packagesSpec.trimEnd() + '\n' + newRow);
console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`);
try {
// 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', ['install'], { cwd: ROOT, stdio: 'inherit' });
execFileSync(
'pnpm',
[
@ -146,7 +135,7 @@ try {
'tsconfig.json',
'spec/packages.md',
],
{ cwd: ROOT, stdio: 'inherit', shell: true },
{ cwd: ROOT, stdio: 'inherit' },
);
} catch (error) {
console.error('Étape automatique échouée, à relancer toi-même :');

View file

@ -30,10 +30,6 @@ 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:*` |
| 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
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.

View file

@ -35,10 +35,6 @@ 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/<catégorie>/<nom>"] 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).

View file

@ -30,4 +30,3 @@ 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 |

View file

@ -13,7 +13,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
"experimentalDecorators": true
}
}

View file

@ -1,11 +1,4 @@
{
"files": [],
"references": [
{
"path": "./packages/client/web-component"
},
{
"path": "./packages/common/service-registry"
}
]
"references": [{ "path": "./packages/client/web-component" }]
}