@obsidiane/meridiane 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +60 -0
  2. package/cli.js +50 -0
  3. package/models.config.example.js +24 -0
  4. package/package.json +37 -0
  5. package/templates/_lib_template/ng-package.json +7 -0
  6. package/templates/_lib_template/package.json +12 -0
  7. package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +9 -0
  8. package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +101 -0
  9. package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +43 -0
  10. package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +74 -0
  11. package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +17 -0
  12. package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +206 -0
  13. package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +43 -0
  14. package/templates/_lib_template/src/lib/facades/facade.factory.ts +36 -0
  15. package/templates/_lib_template/src/lib/facades/facade.interface.ts +35 -0
  16. package/templates/_lib_template/src/lib/facades/resource.facade.ts +115 -0
  17. package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +50 -0
  18. package/templates/_lib_template/src/lib/ports/realtime.port.ts +34 -0
  19. package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +79 -0
  20. package/templates/_lib_template/src/lib/provide-bridge.ts +35 -0
  21. package/templates/_lib_template/src/lib/tokens.ts +7 -0
  22. package/templates/_lib_template/src/lib/utils/utils.ts +21 -0
  23. package/templates/_lib_template/src/models/index.ts +2 -0
  24. package/templates/_lib_template/src/public-api.ts +8 -0
  25. package/templates/_lib_template/tsconfig.lib.json +18 -0
  26. package/templates/_lib_template/tsconfig.lib.prod.json +11 -0
  27. package/templates/_lib_template/tsconfig.spec.json +14 -0
  28. package/tools/generate-lib.js +136 -0
  29. package/tools/generate-models.js +121 -0
  30. package/tools/generator/models/handlebars.js +15 -0
  31. package/tools/generator/models/naming.js +20 -0
  32. package/tools/generator/models/openapi-to-models.js +98 -0
  33. package/tools/generator/models/schema-utils.js +125 -0
  34. package/tools/generator/models/templates/index.hbs +3 -0
  35. package/tools/generator/models/templates/model.hbs +10 -0
  36. package/tools/generator/models/type-resolver.js +96 -0
  37. package/tools/generator/models/utils.js +63 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @obsidiane/meridiane
2
+
3
+ CLI pour générer une librairie Angular “bridge” (Symfony / API Platform / Mercure) et générer des modèles TypeScript depuis une spec OpenAPI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm i -D @obsidiane/meridiane
9
+ ```
10
+
11
+ Ou en global :
12
+
13
+ ```bash
14
+ npm i -g @obsidiane/meridiane
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ # Générer une librairie Angular (dans projects/<lib-name>)
21
+ npx meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
22
+
23
+ # Générer des modèles depuis OpenAPI
24
+ npx meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [--out=<dir>] [--item-import=../lib/ports/resource-repository.port] [--required-mode=all-optional|spec] [--no-index]
25
+ ```
26
+
27
+ `url-registry` est optionnel. Recommandé : gérer le registry via `.npmrc` / variables CI plutôt que de le passer au générateur.
28
+
29
+ ## Exemple CI (générer + build + publish)
30
+
31
+ ```bash
32
+ npm ci
33
+ npx -y @obsidiane/meridiane@0.1.0 lib backend-bridge @acme/backend-bridge 0.1.0
34
+ npx -y @obsidiane/meridiane@0.1.0 models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
35
+ npx ng build backend-bridge
36
+ cd dist/backend-bridge
37
+ npm publish
38
+ ```
39
+
40
+ Si votre workspace ne contient pas `ng-packagr` (souvent le cas dans une app Angular “pure”), installez-le :
41
+
42
+ ```bash
43
+ npm i -D ng-packagr
44
+ ```
45
+
46
+ ## Configuration (models.config.js)
47
+
48
+ Le générateur de modèles charge automatiquement un fichier `models.config.js` s’il existe dans le répertoire courant (CWD).
49
+
50
+ Copie rapide (après installation) :
51
+
52
+ ```bash
53
+ cp node_modules/@obsidiane/meridiane/models.config.example.js ./models.config.js
54
+ ```
55
+
56
+ Sans installation préalable :
57
+
58
+ ```bash
59
+ npx -y @obsidiane/meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
60
+ ```
package/cli.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import {fileURLToPath} from 'node:url';
3
+ import path from 'node:path';
4
+ import {spawn} from 'node:child_process';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ function runTool(scriptName, args) {
10
+ const scriptPath = path.join(__dirname, 'tools', scriptName);
11
+ const child = spawn(process.execPath, [scriptPath, ...args], {
12
+ stdio: 'inherit',
13
+ });
14
+
15
+ child.on('exit', (code) => {
16
+ process.exit(code ?? 0);
17
+ });
18
+ }
19
+
20
+ function printHelp() {
21
+ console.log(`
22
+ Usage:
23
+ meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
24
+ meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [options]
25
+
26
+ Examples:
27
+ npx -y @obsidiane/meridiane lib backend-bridge @acme/backend-bridge 0.1.0
28
+ npx -y @obsidiane/meridiane models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
29
+ `);
30
+ }
31
+
32
+ const [, , cmd, ...rest] = process.argv;
33
+
34
+ switch (cmd) {
35
+ case 'lib':
36
+ runTool('generate-lib.js', rest);
37
+ break;
38
+ case 'models':
39
+ runTool('generate-models.js', rest);
40
+ break;
41
+ case '-h':
42
+ case '--help':
43
+ case undefined:
44
+ printHelp();
45
+ break;
46
+ default:
47
+ console.error(`Unknown command: ${cmd}`);
48
+ printHelp();
49
+ process.exit(1);
50
+ }
@@ -0,0 +1,24 @@
1
+ // Exemple de configuration pour la génération des modèles.
2
+ // Copiez ce fichier en `models.config.js` dans le dossier où vous lancez la commande (`meridiane models`).
3
+ // Format CommonJS pour éviter les warnings Node dans la plupart des workspaces Angular.
4
+
5
+ module.exports = {
6
+ // Dossier de sortie (relatif au CWD)
7
+ outDir: 'projects/backend-bridge/src/models',
8
+
9
+ // Chemin d'import de `Item` dans les templates (relatif au fichier généré)
10
+ itemImportPath: '../lib/ports/resource-repository.port',
11
+
12
+ // Contrôle des propriétés optionnelles
13
+ // - 'all-optional': toutes les props sont optionnelles
14
+ // - 'spec': respecte le tableau `required` de la spec
15
+ requiredMode: 'all-optional',
16
+
17
+ // Préférence entre variantes de schémas (.jsonld, .jsonapi ou aucune)
18
+ // Valeurs possibles: 'jsonld' | 'jsonapi' | 'none'
19
+ preferFlavor: 'jsonld',
20
+
21
+ // Hydra: regex pour les schémas de base à ignorer (merge allOf / filtrage)
22
+ // Peut être une RegExp ou une string passée à `new RegExp(...)`
23
+ hydraBaseRegex: /^Hydra(?:Item|Collection)BaseSchema$/,
24
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@obsidiane/meridiane",
3
+ "version": "0.0.6",
4
+ "description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/obsidiane-lab/meridiane"
8
+ },
9
+ "type": "module",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "scripts": {
14
+ "pack:check": "npm_config_cache=./.npm-cache npm pack --dry-run"
15
+ },
16
+ "bin": {
17
+ "meridiane": "cli.js",
18
+ "meridiane-generate-lib": "tools/generate-lib.js",
19
+ "meridiane-generate-models": "tools/generate-models.js"
20
+ },
21
+ "files": [
22
+ "cli.js",
23
+ "tools/**",
24
+ "templates/**",
25
+ "models.config.example.js",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "dependencies": {
32
+ "fs-extra": "^11.3.1",
33
+ "handlebars": "^4.7.8",
34
+ "openapi-typescript-codegen": "^0.29.0",
35
+ "tslib": "^2.3.0"
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/__LIB_NAME__",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "__LIB_NAME__",
3
+ "version": "0.0.1",
4
+ "peerDependencies": {
5
+ "@angular/common": "^20.1.0",
6
+ "@angular/core": "^20.1.0"
7
+ },
8
+ "dependencies": {
9
+ "tslib": "^2.8.1"
10
+ },
11
+ "sideEffects": false
12
+ }
@@ -0,0 +1,9 @@
1
+ export class CredentialsPolicy {
2
+ constructor(private readonly init: unknown) {
3
+ }
4
+
5
+ withCredentials(): boolean {
6
+ const c = this.init as any;
7
+ return c?.withCredentials === true || c?.credentials === 'include';
8
+ }
9
+ }
@@ -0,0 +1,101 @@
1
+ import {HttpClient} from '@angular/common/http';
2
+ import {Observable} from 'rxjs';
3
+ import {toHttpParams} from './query-builder';
4
+ import {
5
+ Collection,
6
+ CreateCommand,
7
+ HttpRequestConfig,
8
+ Iri, Item,
9
+ Query,
10
+ ResourceRepository,
11
+ UpdateCommand
12
+ } from '../../ports/resource-repository.port';
13
+ import {CredentialsPolicy} from '../credentials.policy';
14
+
15
+
16
+ export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
17
+ private readonly credentialsPolicy: CredentialsPolicy;
18
+
19
+ constructor(
20
+ private readonly http: HttpClient,
21
+ private readonly apiBase: string,
22
+ private readonly resourcePath: Iri,
23
+ readonly init: string,
24
+ ) {
25
+ this.credentialsPolicy = new CredentialsPolicy(init);
26
+ }
27
+
28
+ list$(query?: Query): Observable<Collection<T>> {
29
+ return this.http.get<any>(`${this.apiBase}${this.resourcePath}`, {
30
+ params: toHttpParams(query),
31
+ withCredentials: this.credentialsPolicy.withCredentials()
32
+ });
33
+ }
34
+
35
+ get$(iri: Iri): Observable<T> {
36
+ return this.http.get<T>(`${this.apiBase}${iri}`, {
37
+ withCredentials: this.credentialsPolicy.withCredentials()
38
+ });
39
+ }
40
+
41
+ create$(cmd: CreateCommand<T>): Observable<T> {
42
+ return this.http.post<T>(`${this.apiBase}${this.resourcePath}`, cmd.payload, {
43
+ withCredentials: this.credentialsPolicy.withCredentials()
44
+ });
45
+ }
46
+
47
+ update$(cmd: UpdateCommand<T>): Observable<T> {
48
+ return this.http.patch<T>(`${this.apiBase}${cmd.iri}`, cmd.changes, {
49
+ withCredentials: this.credentialsPolicy.withCredentials()
50
+ });
51
+ }
52
+
53
+ delete$(iri: Iri): Observable<void> {
54
+ return this.http.delete<void>(`${this.apiBase}${iri}`, {
55
+ withCredentials: this.credentialsPolicy.withCredentials()
56
+ });
57
+ }
58
+
59
+ request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
60
+ const {
61
+ method,
62
+ url,
63
+ query,
64
+ body,
65
+ headers,
66
+ responseType,
67
+ withCredentials,
68
+ options = {}
69
+ } = req;
70
+
71
+ const mergedOptions: any = {...options};
72
+ const targetUrl = this.resolveUrl(url ?? this.resourcePath);
73
+
74
+ const computedResponseType = responseType ?? mergedOptions.responseType ?? 'json';
75
+ mergedOptions.responseType = computedResponseType as any;
76
+
77
+ const credentials = withCredentials ?? mergedOptions.withCredentials ?? this.credentialsPolicy.withCredentials();
78
+ mergedOptions.withCredentials = credentials;
79
+
80
+ if (headers) mergedOptions.headers = headers;
81
+ if (query) mergedOptions.params = toHttpParams(query as any);
82
+ if (body !== undefined) mergedOptions.body = body;
83
+
84
+ mergedOptions.observe = 'body';
85
+ return this.http.request<R>(method, targetUrl, mergedOptions as { observe: 'body' });
86
+ }
87
+
88
+ private resolveUrl(path?: Iri): string {
89
+ const effectivePath = path ?? this.resourcePath;
90
+ if (!effectivePath) throw new Error('ApiPlatformRestRepository.resolveUrl: missing url and resourcePath');
91
+ if (/^https?:\/\//i.test(effectivePath)) return effectivePath;
92
+ if (effectivePath.startsWith('//')) return effectivePath;
93
+ return joinUrl(this.apiBase, effectivePath);
94
+ }
95
+ }
96
+
97
+ function joinUrl(base: string, path: string): string {
98
+ const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
99
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
100
+ return `${normalizedBase}${normalizedPath}`;
101
+ }
@@ -0,0 +1,43 @@
1
+ import {HttpParams} from '@angular/common/http';
2
+ import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
3
+
4
+ export function toHttpParams(q: AnyQuery | undefined): HttpParams {
5
+ if (!q) return new HttpParams();
6
+ if (q instanceof HttpParams) return q;
7
+
8
+ const fromObject: Record<string, string | string[]> = {};
9
+
10
+ const consumed = new Set<string>();
11
+ const maybeQuery = q as Query;
12
+ if (maybeQuery.page != null) {
13
+ fromObject['page'] = String(maybeQuery.page);
14
+ consumed.add('page');
15
+ }
16
+ if (maybeQuery.itemsPerPage != null) {
17
+ fromObject['itemsPerPage'] = String(maybeQuery.itemsPerPage);
18
+ consumed.add('itemsPerPage');
19
+ }
20
+
21
+ if (q.filters) {
22
+ consumed.add('filters');
23
+ for (const [k, v] of Object.entries(q.filters)) {
24
+ assign(fromObject, k, v);
25
+ }
26
+ }
27
+
28
+ for (const [k, v] of Object.entries(q as Record<string, QueryParamValue>)) {
29
+ if (consumed.has(k)) continue;
30
+ assign(fromObject, k, v);
31
+ }
32
+
33
+ return new HttpParams({fromObject});
34
+ }
35
+
36
+ function assign(target: Record<string, string | string[]>, key: string, value: QueryParamValue | undefined) {
37
+ if (value == null) return;
38
+ if (Array.isArray(value)) {
39
+ target[key] = value.map(String);
40
+ } else {
41
+ target[key] = String(value);
42
+ }
43
+ }
@@ -0,0 +1,74 @@
1
+ import {ReplaySubject, Subject} from 'rxjs';
2
+ import {SseEvent, SseOptions, RealtimeStatus} from '../../ports/realtime.port';
3
+
4
+ export class EventSourceWrapper {
5
+ private es?: EventSource;
6
+
7
+ private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
8
+ private readonly eventSub = new Subject<SseEvent>();
9
+
10
+ readonly status$ = this.statusSub.asObservable();
11
+ readonly events$ = this.eventSub.asObservable();
12
+
13
+ /** Activer pour conversations local */
14
+ private readonly debug = false;
15
+
16
+ constructor(
17
+ private readonly url: string,
18
+ private readonly opts: SseOptions = {}
19
+ ) {
20
+ this.setState('closed');
21
+ this.log('[SSE] initialized', url, opts);
22
+ }
23
+
24
+ open(): void {
25
+ if (this.es) {
26
+ this.log('[SSE] open() ignored: already open');
27
+ return;
28
+ }
29
+
30
+ this.setState('connecting');
31
+ this.log('[SSE] creating EventSource');
32
+
33
+ const es = new EventSource(this.url, {
34
+ withCredentials: !!this.opts.withCredentials,
35
+ });
36
+ this.es = es;
37
+
38
+ es.onopen = () => {
39
+ this.setState('connected');
40
+ };
41
+
42
+ es.onmessage = (ev) => {
43
+ this.log('[SSE] event', ev);
44
+ this.log('[SSE] message', ev.data);
45
+ this.eventSub.next({type: 'message', data: ev.data, lastEventId: ev.lastEventId || undefined});
46
+ };
47
+
48
+ es.onerror = () => {
49
+ // Le navigateur va retenter. On reste en "connecting".
50
+ this.log('[SSE] error → retrying (browser)');
51
+ this.setState('connecting');
52
+ };
53
+ }
54
+
55
+ close(): void {
56
+ if (this.es) {
57
+ this.es.close();
58
+ this.es = undefined;
59
+ this.log('[SSE] closed');
60
+ }
61
+ this.setState('closed');
62
+ }
63
+
64
+ // ──────────────── internals ────────────────
65
+
66
+ private setState(state: RealtimeStatus): void {
67
+ this.log('[SSE] state', state);
68
+ this.statusSub.next(state);
69
+ }
70
+
71
+ private log(...args: unknown[]): void {
72
+ if (this.debug) console.log(...args);
73
+ }
74
+ }
@@ -0,0 +1,17 @@
1
+ export class MercureUrlBuilder {
2
+ constructor(private readonly apiBase: string) {
3
+ }
4
+
5
+
6
+ build(hubUrl: string, iris: ReadonlySet<string>, lastEventId?: string): string {
7
+ const url = new URL(hubUrl);
8
+ if (lastEventId) {
9
+ url.searchParams.set('lastEventID', lastEventId);
10
+ }
11
+ url.searchParams.delete('topic');
12
+ for (const iri of iris) {
13
+ url.searchParams.append('topic', this.apiBase + iri);
14
+ }
15
+ return url.toString();
16
+ }
17
+ }
@@ -0,0 +1,206 @@
1
+ import {DOCUMENT, Inject, Injectable, OnDestroy, PLATFORM_ID} from '@angular/core';
2
+ import {BehaviorSubject, defer, fromEvent, Observable, of, Subject} from 'rxjs';
3
+ import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
4
+ import {API_BASE_URL, MERCURE_CONFIG, MERCURE_HUB_URL} from '../../tokens';
5
+ import {RealtimeEvent, RealtimePort, RealtimeStatus} from '../../ports/realtime.port';
6
+ import {EventSourceWrapper} from './eventsource-wrapper';
7
+ import {Item} from "../../ports/resource-repository.port";
8
+ import {isPlatformBrowser} from '@angular/common';
9
+ import {MercureUrlBuilder} from './mercure-url.builder';
10
+ import {RefCountTopicRegistry} from './ref-count-topic.registry';
11
+ import {CredentialsPolicy} from '../credentials.policy';
12
+
13
+
14
+ @Injectable({providedIn: 'root'})
15
+ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
16
+ private lastEventId?: string;
17
+ private es?: EventSourceWrapper;
18
+ private currentKey?: string;
19
+
20
+ private readonly topicsRegistry = new RefCountTopicRegistry();
21
+ private readonly urlBuilder: MercureUrlBuilder;
22
+ private readonly credentialsPolicy: CredentialsPolicy;
23
+
24
+ private readonly destroy$ = new Subject<void>();
25
+ private connectionStop$ = new Subject<void>();
26
+ private readonly rebuild$ = new Subject<void>();
27
+ private shuttingDown = false;
28
+
29
+ private readonly _status$ = new BehaviorSubject<RealtimeStatus>('closed');
30
+ private readonly incoming$ = new Subject<{ type: string; data: string }>();
31
+
32
+ constructor(
33
+ @Inject(API_BASE_URL) private readonly apiBase: string,
34
+ @Inject(MERCURE_CONFIG) private readonly init: string,
35
+ @Inject(MERCURE_HUB_URL) private readonly hubUrl: string,
36
+ @Inject(DOCUMENT) private readonly doc: Document,
37
+ @Inject(PLATFORM_ID) private readonly platformId: object,
38
+ ) {
39
+ this.urlBuilder = new MercureUrlBuilder(apiBase);
40
+ this.credentialsPolicy = new CredentialsPolicy(init);
41
+
42
+ this.rebuild$
43
+ .pipe(
44
+ auditTime(10),
45
+ concatMap(() => this.rebuildOnce$()),
46
+ )
47
+ .subscribe();
48
+
49
+ if (isPlatformBrowser(this.platformId)) {
50
+ fromEvent<PageTransitionEvent>(window, 'pagehide')
51
+ .pipe(takeUntil(this.destroy$))
52
+ .subscribe(() => this.shutdownBeforeExit());
53
+
54
+ fromEvent<BeforeUnloadEvent>(window, 'beforeunload')
55
+ .pipe(takeUntil(this.destroy$))
56
+ .subscribe(() => this.shutdownBeforeExit());
57
+ }
58
+ }
59
+
60
+ // ──────────────── API publique ────────────────
61
+
62
+ status$(): Observable<RealtimeStatus> {
63
+ return this._status$.asObservable();
64
+ }
65
+
66
+
67
+ subscribe$<T>(iris: string[], _filter?: { field?: string }): Observable<RealtimeEvent<T>> {
68
+ if (iris.length === 0) {
69
+ return new Observable<RealtimeEvent<T>>((sub) => sub.complete());
70
+ }
71
+
72
+ this.topicsRegistry.addAll(iris);
73
+ this.scheduleRebuild();
74
+
75
+ const filterSet = new Set(iris);
76
+ const fieldPath = _filter?.field;
77
+
78
+ return this.incoming$.pipe(
79
+ map((evt) => this.safeParse(evt.data)),
80
+
81
+ filter((raw: any) => {
82
+ if (fieldPath) {
83
+ const relIri = this.extractRelationIris(raw, fieldPath);
84
+ return Array.from(filterSet).some((iri) => relIri === iri || relIri?.startsWith(`${iri}/`));
85
+ }
86
+ const id = raw?.['@id'];
87
+ return typeof id === 'string' && Array.from(filterSet).some((iri) => {
88
+ const normalized = iri.endsWith('/') ? iri.slice(0, -1) : iri;
89
+ return id === normalized || id.startsWith(`${normalized}/`);
90
+ });
91
+ }),
92
+ map((payload) => ({iri: payload['@id'] as string, data: payload as T})),
93
+ finalize(() => {
94
+ this.topicsRegistry.removeAll(iris);
95
+ this.scheduleRebuild();
96
+ }),
97
+ share()
98
+ );
99
+ }
100
+
101
+ unsubscribe(iris: string[]): void {
102
+ if (!iris || iris.length === 0) return;
103
+ this.topicsRegistry.removeAll(iris);
104
+ this.scheduleRebuild();
105
+ }
106
+
107
+ public shutdownBeforeExit(): void {
108
+ if (this.shuttingDown) return;
109
+ this.shuttingDown = true;
110
+ this.teardownConnection();
111
+ }
112
+
113
+ ngOnDestroy(): void {
114
+ this.teardownConnection();
115
+ this._status$.complete();
116
+ this.incoming$.complete();
117
+ this.destroy$.next();
118
+ this.destroy$.complete();
119
+ this.rebuild$.complete();
120
+ }
121
+
122
+ // ───────────────── PRIVATE ─────────────────
123
+
124
+ private scheduleRebuild(): void {
125
+ if (this.shuttingDown) return;
126
+ this.rebuild$.next();
127
+ }
128
+
129
+ private rebuildOnce$() {
130
+ return defer(() => {
131
+ if (this.shuttingDown) return of(void 0);
132
+
133
+ try {
134
+ const hasTopics = this.topicsRegistry.hasAny();
135
+ const key = hasTopics ? this.topicsRegistry.computeKey(this.hubUrl, this.credentialsPolicy.withCredentials()) : undefined;
136
+
137
+ if (!hasTopics) {
138
+ if (this.es) this.teardownConnection();
139
+ this.currentKey = undefined;
140
+ this._status$.next('closed');
141
+ return of(void 0);
142
+ }
143
+
144
+ if (key && key === this.currentKey) {
145
+ return of(void 0);
146
+ }
147
+
148
+ this.teardownConnection();
149
+
150
+ const url = this.urlBuilder.build(this.hubUrl, this.topicsRegistry.snapshot(), this.lastEventId);
151
+ this.es = new EventSourceWrapper(url, {withCredentials: this.credentialsPolicy.withCredentials()});
152
+
153
+ this.connectionStop$ = new Subject<void>();
154
+
155
+ this.es.status$
156
+ .pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
157
+ .subscribe((st) => this.updateGlobalStatus(st));
158
+
159
+ this.es.events$
160
+ .pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
161
+ .subscribe((e) => {
162
+ this.lastEventId = e.lastEventId ?? this.lastEventId;
163
+ this.incoming$.next(e);
164
+ });
165
+
166
+ this._status$.next('connecting');
167
+ this.es.open();
168
+ this.currentKey = key;
169
+ } catch (err) {
170
+ console.error('[Mercure] rebuild failed:', err);
171
+ this.currentKey = undefined;
172
+ this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
173
+ }
174
+
175
+ return of(void 0);
176
+ });
177
+ }
178
+
179
+ private teardownConnection(): void {
180
+ this.es?.close();
181
+ this.es = undefined;
182
+ this.connectionStop$.next();
183
+ this.connectionStop$.complete();
184
+ }
185
+
186
+ private updateGlobalStatus(sse: RealtimeStatus): void {
187
+ if (sse === 'connected') {
188
+ this._status$.next('connected');
189
+ return;
190
+ }
191
+ if (sse === 'connecting') {
192
+ this._status$.next('connecting');
193
+ return;
194
+ }
195
+ this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
196
+ }
197
+
198
+ private safeParse(raw: string): Item {
199
+ return JSON.parse(raw) as Item;
200
+ }
201
+
202
+ private extractRelationIris(raw: any, path: string): string {
203
+ return raw?.[path];
204
+ }
205
+
206
+ }
@@ -0,0 +1,43 @@
1
+ export class RefCountTopicRegistry {
2
+ private readonly iris = new Set<string>();
3
+ private readonly refCounts = new Map<string, number>();
4
+
5
+
6
+ addAll(iris: Iterable<string>): void {
7
+ for (const iri of iris) {
8
+ const next = (this.refCounts.get(iri) ?? 0) + 1;
9
+ this.refCounts.set(iri, next);
10
+ this.iris.add(iri);
11
+ }
12
+ }
13
+
14
+
15
+ removeAll(iris: Iterable<string>): void {
16
+ for (const iri of iris) {
17
+ const next = (this.refCounts.get(iri) ?? 0) - 1;
18
+ if (next <= 0) {
19
+ this.refCounts.delete(iri);
20
+ this.iris.delete(iri);
21
+ } else {
22
+ this.refCounts.set(iri, next);
23
+ }
24
+ }
25
+ }
26
+
27
+
28
+ hasAny(): boolean {
29
+ return this.iris.size > 0;
30
+ }
31
+
32
+
33
+ snapshot(): ReadonlySet<string> {
34
+ return new Set(this.iris);
35
+ }
36
+
37
+
38
+
39
+ computeKey(hubUrl: string, credentialsOn: boolean): string {
40
+ const topicsSorted = Array.from(this.iris).sort().join('|');
41
+ return `${hubUrl}::${credentialsOn ? '1' : '0'}::${topicsSorted}`;
42
+ }
43
+ }