@obsidiane/meridiane 2.0.10 → 2.0.12

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 (91) hide show
  1. package/README.md +19 -2
  2. package/cli.js +21 -37
  3. package/package.json +13 -6
  4. package/src/README.md +9 -0
  5. package/{tools/app → src/application}/bridge-workflow.js +29 -23
  6. package/src/application/generate-bridge.js +50 -0
  7. package/{tools/app → src/application}/run-build.js +1 -1
  8. package/{tools/app → src/application}/run-dev.js +30 -36
  9. package/{tools/app → src/application}/run-generate.js +3 -3
  10. package/{tools → src}/domain/bridge-options.js +8 -21
  11. package/src/domain/list-values.js +6 -0
  12. package/src/generators/bridge/write-bridge-files.js +100 -0
  13. package/src/generators/models/interface-schema.js +116 -0
  14. package/src/generators/models/model-selection.js +148 -0
  15. package/src/generators/models/oas-contract.js +170 -0
  16. package/{tools/generator → src/generators}/models/oas-schema-name.js +6 -15
  17. package/src/generators/models/openapi-to-models.js +136 -0
  18. package/{tools/generator → src/generators}/models/schema-utils.js +27 -3
  19. package/src/generators/models/templates/type-alias.hbs +2 -0
  20. package/src/generators/models/type-expression.js +65 -0
  21. package/src/generators/models/type-resolver.js +240 -0
  22. package/src/generators/models/utils.js +89 -0
  23. package/src/generators/models/write-model-files.js +55 -0
  24. package/src/infrastructure/dev-project.js +48 -0
  25. package/{tools/infra → src/infrastructure}/exec.js +3 -2
  26. package/src/infrastructure/local-package-install.js +104 -0
  27. package/src/infrastructure/spec-loader.js +65 -0
  28. package/templates/{_lib_template → angular-bridge}/README.md +9 -0
  29. package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +82 -0
  30. package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +30 -0
  31. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/rest/query-builder.ts +10 -10
  32. package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +255 -0
  33. package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +82 -0
  34. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +39 -0
  35. package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +358 -0
  36. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/ref-count-topic.registry.ts +3 -12
  37. package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +76 -0
  38. package/templates/angular-bridge/src/lib/bridge-options.ts +59 -0
  39. package/templates/{_lib_template → angular-bridge}/src/lib/facades/bridge.facade.ts +50 -51
  40. package/templates/{_lib_template → angular-bridge}/src/lib/facades/facade.factory.ts +0 -1
  41. package/templates/angular-bridge/src/lib/facades/facade.interface.ts +13 -0
  42. package/templates/{_lib_template → angular-bridge}/src/lib/facades/resource.facade.ts +24 -31
  43. package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +33 -0
  44. package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +65 -0
  45. package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +37 -0
  46. package/templates/angular-bridge/src/lib/interceptors/scoped.interceptor.ts +9 -0
  47. package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +46 -0
  48. package/templates/{_lib_template → angular-bridge}/src/lib/ports/realtime.port.ts +6 -2
  49. package/templates/{_lib_template → angular-bridge}/src/lib/ports/resource-repository.port.ts +7 -7
  50. package/templates/angular-bridge/src/lib/provide-bridge.ts +98 -0
  51. package/templates/angular-bridge/src/lib/utils/iri.ts +6 -0
  52. package/templates/angular-bridge/src/lib/utils/request-url.ts +31 -0
  53. package/templates/{_lib_template → angular-bridge}/src/public-api.ts +2 -0
  54. package/templates/{_lib_template → angular-bridge}/tsconfig.lib.json +13 -2
  55. package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +0 -84
  56. package/templates/_lib_template/src/lib/bridge/rest/http-request.options.ts +0 -21
  57. package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +0 -17
  58. package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +0 -806
  59. package/templates/_lib_template/src/lib/facades/facade.interface.ts +0 -31
  60. package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +0 -53
  61. package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +0 -49
  62. package/templates/_lib_template/src/lib/interceptors/singleflight.interceptor.ts +0 -55
  63. package/templates/_lib_template/src/lib/provide-bridge.ts +0 -186
  64. package/tools/build.js +0 -1
  65. package/tools/dev.js +0 -1
  66. package/tools/domain/paths.js +0 -10
  67. package/tools/generate.js +0 -1
  68. package/tools/generator/models/oas-contract.js +0 -159
  69. package/tools/generator/models/openapi-to-models.js +0 -240
  70. package/tools/generator/models/type-resolver.js +0 -107
  71. package/tools/generator/models/utils.js +0 -62
  72. package/tools/infra/dev-support.js +0 -189
  73. package/tools/infra/generate/bridge.js +0 -212
  74. package/tools/infra/spec-loader.js +0 -59
  75. package/tools/infra/standalone-workspace.js +0 -162
  76. /package/{tools → src}/domain/lib-name.js +0 -0
  77. /package/{tools/generator → src/generators}/models/handlebars.js +0 -0
  78. /package/{tools/generator → src/generators}/models/templates/index.hbs +0 -0
  79. /package/{tools/generator → src/generators}/models/templates/model.hbs +0 -0
  80. /package/{tools/infra → src/infrastructure}/json.js +0 -0
  81. /package/{tools/infra → src/infrastructure}/logger.js +0 -0
  82. /package/templates/{_lib_template → angular-bridge}/ng-package.json +0 -0
  83. /package/templates/{_lib_template → angular-bridge}/package.json +0 -0
  84. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/eventsource-wrapper.ts +0 -0
  85. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -0
  86. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge.types.ts +0 -0
  87. /package/templates/{_lib_template → angular-bridge}/src/lib/interceptors/bridge-debug.interceptor.ts +0 -0
  88. /package/templates/{_lib_template → angular-bridge}/src/lib/tokens.ts +0 -0
  89. /package/templates/{_lib_template → angular-bridge}/src/lib/utils/url.ts +0 -0
  90. /package/templates/{_lib_template → angular-bridge}/src/models/index.ts +0 -0
  91. /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.prod.json +0 -0
@@ -67,6 +67,10 @@ export class AppModule {}
67
67
  - `debug` (logs runtime) ;
68
68
  - `extraInterceptors` (interceptors Angular additionnels).
69
69
 
70
+ Les comportements HTTP internes du bridge s'appliquent uniquement aux requêtes
71
+ appartenant à l'origine et au chemin de `baseUrl`. Les interceptors personnalisés
72
+ restent responsables de leur propre périmètre.
73
+
70
74
  ### Cookies / `withCredentials`
71
75
 
72
76
  Le bridge peut envoyer des cookies (sessions) côté HTTP et SSE.
@@ -105,6 +109,11 @@ provideBridge({
105
109
  - `{ type: 'bearer', getToken }` (sync ou async),
106
110
  - ou un `HttpInterceptorFn` custom.
107
111
 
112
+ Le Bearer intégré est ajouté uniquement aux requêtes dont l’origine et le chemin
113
+ appartiennent au `baseUrl` configuré. Une requête vers un autre service conserve
114
+ donc ses en-têtes d’origine. Un interceptor custom garde le comportement Angular
115
+ standard et reste responsable de son propre périmètre.
116
+
108
117
  ```ts
109
118
  import {provideBridge} from '__PACKAGE_NAME__';
110
119
 
@@ -0,0 +1,82 @@
1
+ import {HttpClient} from '@angular/common/http';
2
+ import {Observable} from 'rxjs';
3
+ import {toHttpParams} from './query-builder';
4
+ import {buildHttpRequestOptions, resolveHttpCallOptions} from './http-request.options';
5
+ import {
6
+ Collection,
7
+ HttpCallOptions,
8
+ HttpRequestConfig,
9
+ Iri,
10
+ IriRequired,
11
+ Item,
12
+ AnyQuery,
13
+ ResourceRepository,
14
+ } from '../../ports/resource-repository.port';
15
+ import {resolveUrl} from '../../utils/url';
16
+
17
+ export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
18
+ constructor(
19
+ private readonly http: HttpClient,
20
+ private readonly apiBase: string,
21
+ private readonly resourcePath: Iri,
22
+ private readonly httpWithCredentialsDefault: boolean,
23
+ ) {
24
+ }
25
+
26
+ getCollection$(query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
27
+ return this.http.get<Collection<T>>(this.resolveUrl(this.resourcePath), {
28
+ ...resolveHttpCallOptions(options, this.httpWithCredentialsDefault),
29
+ params: toHttpParams(query),
30
+ });
31
+ }
32
+
33
+ get$(iri: IriRequired, options?: HttpCallOptions): Observable<T> {
34
+ return this.http.get<T>(this.resolveUrl(iri), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
35
+ }
36
+
37
+ post$(payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
38
+ return this.http.post<T>(
39
+ this.resolveUrl(this.resourcePath),
40
+ payload,
41
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
42
+ );
43
+ }
44
+
45
+ patch$(iri: IriRequired, changes: Partial<T>, options?: HttpCallOptions): Observable<T> {
46
+ return this.http.patch<T>(
47
+ this.resolveUrl(iri),
48
+ changes,
49
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
50
+ );
51
+ }
52
+
53
+ put$(iri: IriRequired, payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
54
+ return this.http.put<T>(
55
+ this.resolveUrl(iri),
56
+ payload,
57
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
58
+ );
59
+ }
60
+
61
+ delete$(iri: IriRequired, options?: HttpCallOptions): Observable<void> {
62
+ return this.http.delete<void>(
63
+ this.resolveUrl(iri),
64
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
65
+ );
66
+ }
67
+
68
+ request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
69
+ // Low-level escape hatch for non-standard endpoints (custom controllers, uploads, etc.).
70
+ const {method, url} = request;
71
+
72
+ const targetUrl = this.resolveUrl(url ?? this.resourcePath);
73
+ const mergedOptions = buildHttpRequestOptions(request, {withCredentialsDefault: this.httpWithCredentialsDefault});
74
+ return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
75
+ }
76
+
77
+ private resolveUrl(path?: Iri): string {
78
+ const effectivePath = path ?? this.resourcePath;
79
+ if (!effectivePath) throw new Error('ApiPlatformRestRepository: missing url and resourcePath');
80
+ return resolveUrl(this.apiBase, effectivePath);
81
+ }
82
+ }
@@ -0,0 +1,30 @@
1
+ import {HttpCallOptions, HttpRequestConfig} from '../../ports/resource-repository.port';
2
+ import {toHttpParams} from './query-builder';
3
+
4
+ export function resolveHttpCallOptions(options: HttpCallOptions | undefined, withCredentialsDefault: boolean) {
5
+ return {
6
+ headers: options?.headers,
7
+ withCredentials: options?.withCredentials ?? withCredentialsDefault,
8
+ };
9
+ }
10
+
11
+ export function buildHttpRequestOptions(
12
+ req: HttpRequestConfig,
13
+ {withCredentialsDefault}: {withCredentialsDefault: boolean}
14
+ ): Record<string, unknown> {
15
+ const {query, body, headers, responseType, withCredentials, options = {}} = req;
16
+ const mergedOptions: Record<string, unknown> = {...options};
17
+
18
+ if (headers) mergedOptions['headers'] = headers;
19
+ if (query) mergedOptions['params'] = toHttpParams(query);
20
+ if (body !== undefined) mergedOptions['body'] = body;
21
+
22
+ const inheritedResponseType = mergedOptions['responseType'];
23
+ const inheritedWithCredentials = mergedOptions['withCredentials'];
24
+
25
+ mergedOptions['responseType'] = responseType ?? inheritedResponseType ?? 'json';
26
+ mergedOptions['withCredentials'] = withCredentials ?? inheritedWithCredentials ?? withCredentialsDefault;
27
+ mergedOptions['observe'] = 'body';
28
+
29
+ return mergedOptions;
30
+ }
@@ -1,14 +1,14 @@
1
1
  import {HttpParams} from '@angular/common/http';
2
2
  import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
3
3
 
4
- export function toHttpParams(q: AnyQuery | undefined): HttpParams {
5
- if (!q) return new HttpParams();
6
- if (q instanceof HttpParams) return q;
4
+ export function toHttpParams(query: AnyQuery | undefined): HttpParams {
5
+ if (!query) return new HttpParams();
6
+ if (query instanceof HttpParams) return query;
7
7
 
8
8
  const fromObject: Record<string, string | string[]> = {};
9
9
 
10
10
  const consumed = new Set<string>();
11
- const maybeQuery = q as Query;
11
+ const maybeQuery = query as Query;
12
12
  if (maybeQuery.page != null) {
13
13
  fromObject['page'] = String(maybeQuery.page);
14
14
  consumed.add('page');
@@ -18,16 +18,16 @@ export function toHttpParams(q: AnyQuery | undefined): HttpParams {
18
18
  consumed.add('itemsPerPage');
19
19
  }
20
20
 
21
- if (q.filters) {
21
+ if (query.filters) {
22
22
  consumed.add('filters');
23
- for (const [k, v] of Object.entries(q.filters)) {
24
- assign(fromObject, k, v);
23
+ for (const [key, value] of Object.entries(query.filters)) {
24
+ assign(fromObject, key, value);
25
25
  }
26
26
  }
27
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);
28
+ for (const [key, value] of Object.entries(query as Record<string, QueryParamValue>)) {
29
+ if (consumed.has(key)) continue;
30
+ assign(fromObject, key, value);
31
31
  }
32
32
 
33
33
  return new HttpParams({fromObject});
@@ -0,0 +1,255 @@
1
+ import {BehaviorSubject, Observable, Subject} from 'rxjs';
2
+ import {takeUntil} from 'rxjs/operators';
3
+ import {
4
+ BridgeLogger,
5
+ MercureConnectionMode,
6
+ RealtimeDiagnostics,
7
+ } from '../../bridge.types';
8
+ import {RealtimeStatus} from '../../ports/realtime.port';
9
+ import {EventSourceWrapper} from './eventsource-wrapper';
10
+ import {MercureUrlBuilder} from './mercure-url.builder';
11
+
12
+ export type IncomingMessage = {type: string; data: string; lastEventId?: string};
13
+
14
+ type ConnectionScope = 'shared' | 'dedicated';
15
+
16
+ type ManagedConnection = {
17
+ id: string;
18
+ key: string;
19
+ scope: ConnectionScope;
20
+ groupId?: string;
21
+ topics: ReadonlySet<string>;
22
+ urlLength: number;
23
+ wrapper: EventSourceWrapper;
24
+ stop$: Subject<void>;
25
+ status: RealtimeStatus;
26
+ };
27
+
28
+ type ConnectionTarget = Subject<IncomingMessage>;
29
+
30
+ type ConnectionManagerOptions = {
31
+ hubUrl?: string;
32
+ withCredentials: boolean;
33
+ mode: MercureConnectionMode;
34
+ maxUrlLength: number;
35
+ logger?: BridgeLogger;
36
+ destroy$: Observable<void>;
37
+ nextId: (prefix: string) => string;
38
+ };
39
+
40
+ export class MercureConnectionManager {
41
+ private readonly urlBuilder = new MercureUrlBuilder();
42
+ private readonly connections = new Map<string, ManagedConnection>();
43
+ private readonly sharedConnections = new Map<string, ManagedConnection>();
44
+ private readonly dedicatedGroups = new Map<string, Set<string>>();
45
+ private readonly lastEventIdByKey = new Map<string, string>();
46
+
47
+ private readonly statusSubject = new BehaviorSubject<RealtimeStatus>('closed');
48
+ private readonly diagnosticsSubject: BehaviorSubject<RealtimeDiagnostics>;
49
+
50
+ readonly status$ = this.statusSubject.asObservable();
51
+ readonly diagnostics$: Observable<RealtimeDiagnostics>;
52
+
53
+ constructor(private readonly options: ConnectionManagerOptions) {
54
+ this.diagnosticsSubject = new BehaviorSubject<RealtimeDiagnostics>(this.createDiagnostics());
55
+ this.diagnostics$ = this.diagnosticsSubject.asObservable();
56
+ }
57
+
58
+ reconcileShared(topics: Iterable<string>, target: ConnectionTarget): void {
59
+ if (!this.options.hubUrl) {
60
+ this.closeShared();
61
+ return;
62
+ }
63
+
64
+ const desiredConnections = new Map<string, ReadonlySet<string>>();
65
+ for (const partition of this.partitionTopics(topics)) {
66
+ const partitionTopics = new Set(partition);
67
+ desiredConnections.set(this.connectionKey(partitionTopics, 'shared'), partitionTopics);
68
+ }
69
+
70
+ for (const [key, connection] of [...this.sharedConnections.entries()]) {
71
+ if (!desiredConnections.has(key)) this.close(connection.id);
72
+ }
73
+
74
+ for (const [key, partitionTopics] of desiredConnections) {
75
+ if (this.sharedConnections.has(key)) continue;
76
+ const connection = this.open({key, scope: 'shared', topics: partitionTopics, target});
77
+ this.sharedConnections.set(key, connection);
78
+ }
79
+ }
80
+
81
+ openDedicated(groupId: string, topics: Iterable<string>, target: ConnectionTarget): void {
82
+ this.dedicatedGroups.set(groupId, new Set());
83
+
84
+ for (const partition of this.partitionTopics(topics)) {
85
+ const partitionTopics = new Set(partition);
86
+ const key = this.connectionKey(partitionTopics, 'dedicated', groupId);
87
+ const connection = this.open({key, scope: 'dedicated', groupId, topics: partitionTopics, target});
88
+ this.dedicatedGroups.get(groupId)?.add(connection.id);
89
+ }
90
+ }
91
+
92
+ closeShared(): void {
93
+ for (const connection of [...this.sharedConnections.values()]) this.close(connection.id);
94
+ }
95
+
96
+ closeDedicated(groupId: string): void {
97
+ const connectionIds = this.dedicatedGroups.get(groupId);
98
+ if (!connectionIds) return;
99
+
100
+ for (const connectionId of [...connectionIds]) this.close(connectionId);
101
+ this.dedicatedGroups.delete(groupId);
102
+ }
103
+
104
+ closeAll(): void {
105
+ for (const connection of [...this.connections.values()]) this.close(connection.id);
106
+ this.sharedConnections.clear();
107
+ this.dedicatedGroups.clear();
108
+ this.updateStatus();
109
+ }
110
+
111
+ complete(): void {
112
+ this.statusSubject.complete();
113
+ this.diagnosticsSubject.complete();
114
+ }
115
+
116
+ private partitionTopics(topics: Iterable<string>): string[][] {
117
+ if (!this.options.hubUrl) return [];
118
+ return this.urlBuilder.partitionTopics(
119
+ this.options.hubUrl,
120
+ topics,
121
+ this.options.maxUrlLength,
122
+ this.options.mode === 'auto'
123
+ );
124
+ }
125
+
126
+ private open(params: {
127
+ key: string;
128
+ scope: ConnectionScope;
129
+ topics: ReadonlySet<string>;
130
+ target: ConnectionTarget;
131
+ groupId?: string;
132
+ }): ManagedConnection {
133
+ const {hubUrl, withCredentials, logger, destroy$, nextId} = this.options;
134
+ if (!hubUrl) throw new Error('Mercure hub URL is required to open a realtime connection.');
135
+
136
+ const lastEventId = this.lastEventIdByKey.get(params.key);
137
+ const url = this.urlBuilder.build(hubUrl, params.topics, lastEventId);
138
+
139
+ logger?.debug?.('[Mercure] connect', {
140
+ mode: this.options.mode,
141
+ scope: params.scope,
142
+ key: params.key,
143
+ urlLength: url.length,
144
+ topics: [...params.topics],
145
+ lastEventId,
146
+ });
147
+
148
+ const wrapper = new EventSourceWrapper(url, {withCredentials}, logger);
149
+ const stop$ = new Subject<void>();
150
+ const connection: ManagedConnection = {
151
+ id: nextId('conn'),
152
+ key: params.key,
153
+ scope: params.scope,
154
+ groupId: params.groupId,
155
+ topics: params.topics,
156
+ urlLength: url.length,
157
+ wrapper,
158
+ stop$,
159
+ status: 'connecting',
160
+ };
161
+
162
+ this.connections.set(connection.id, connection);
163
+
164
+ wrapper.status$
165
+ .pipe(takeUntil(stop$), takeUntil(destroy$))
166
+ .subscribe((status) => {
167
+ connection.status = status;
168
+ this.updateStatus();
169
+ });
170
+
171
+ wrapper.events$
172
+ .pipe(takeUntil(stop$), takeUntil(destroy$))
173
+ .subscribe((event) => {
174
+ if (event.lastEventId) this.lastEventIdByKey.set(params.key, event.lastEventId);
175
+ params.target.next({type: event.type, data: event.data, lastEventId: event.lastEventId});
176
+ });
177
+
178
+ try {
179
+ wrapper.open();
180
+ this.updateStatus();
181
+ return connection;
182
+ } catch (error) {
183
+ this.close(connection.id);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ private close(connectionId: string): void {
189
+ const connection = this.connections.get(connectionId);
190
+ if (!connection) return;
191
+
192
+ connection.status = 'closed';
193
+ connection.wrapper.close();
194
+ connection.stop$.next();
195
+ connection.stop$.complete();
196
+ this.connections.delete(connectionId);
197
+
198
+ if (connection.scope === 'shared') this.sharedConnections.delete(connection.key);
199
+ if (connection.scope === 'dedicated' && connection.groupId) {
200
+ const group = this.dedicatedGroups.get(connection.groupId);
201
+ group?.delete(connection.id);
202
+ if (group?.size === 0) this.dedicatedGroups.delete(connection.groupId);
203
+ this.lastEventIdByKey.delete(connection.key);
204
+ }
205
+
206
+ this.updateStatus();
207
+ }
208
+
209
+ private connectionKey(topics: ReadonlySet<string>, scope: ConnectionScope, groupId = ''): string {
210
+ const {hubUrl = '', withCredentials, mode} = this.options;
211
+ return [
212
+ scope,
213
+ groupId,
214
+ hubUrl,
215
+ withCredentials ? '1' : '0',
216
+ mode,
217
+ [...topics].sort().join('|'),
218
+ ].join('::');
219
+ }
220
+
221
+ private updateStatus(): void {
222
+ const statuses = [...this.connections.values()].map((connection) => connection.status);
223
+ this.statusSubject.next(aggregateStatus(statuses));
224
+ this.diagnosticsSubject.next(this.createDiagnostics());
225
+ }
226
+
227
+ private createDiagnostics(): RealtimeDiagnostics {
228
+ const connections = [...this.connections.values()]
229
+ .map((connection) => ({
230
+ id: connection.id,
231
+ scope: connection.scope,
232
+ status: connection.status,
233
+ topicCount: connection.topics.size,
234
+ topics: [...connection.topics].sort(),
235
+ urlLength: connection.urlLength,
236
+ }))
237
+ .sort((left, right) => left.scope.localeCompare(right.scope) || left.id.localeCompare(right.id));
238
+ const sharedConnections = connections.filter((connection) => connection.scope === 'shared').length;
239
+
240
+ return {
241
+ mode: this.options.mode,
242
+ maxUrlLength: this.options.maxUrlLength,
243
+ totalConnections: connections.length,
244
+ sharedConnections,
245
+ dedicatedConnections: connections.length - sharedConnections,
246
+ connections,
247
+ };
248
+ }
249
+ }
250
+
251
+ function aggregateStatus(statuses: RealtimeStatus[]): RealtimeStatus {
252
+ if (statuses.includes('connected')) return 'connected';
253
+ if (statuses.includes('connecting')) return 'connecting';
254
+ return 'closed';
255
+ }
@@ -0,0 +1,82 @@
1
+ import {BridgeLogger} from '../../bridge.types';
2
+ import {SubscribeFilter} from '../../ports/realtime.port';
3
+ import {Item} from '../../ports/resource-repository.port';
4
+ import {MercureTopicMapper} from './mercure-topic.mapper';
5
+
6
+ export function parseMercurePayload(raw: string, logger?: BridgeLogger): Item | undefined {
7
+ try {
8
+ const value: unknown = JSON.parse(raw);
9
+ return isRecord(value) ? value as Item : undefined;
10
+ } catch {
11
+ logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
12
+ return undefined;
13
+ }
14
+ }
15
+
16
+ export function createPayloadTopicMatcher(
17
+ iris: string[],
18
+ filter: SubscribeFilter | undefined,
19
+ topicMapper: MercureTopicMapper
20
+ ): (payload: Item) => string | undefined {
21
+ const subscribedTopics = iris.map((iri) => normalizeIri(topicMapper.toPayloadIri(iri)));
22
+ const fieldPaths = [
23
+ ...(filter?.fields?.filter((field): field is string => typeof field === 'string' && field.length > 0) ?? []),
24
+ ...(typeof filter?.field === 'string' && filter.field.length > 0 ? [filter.field] : []),
25
+ ];
26
+ const includeSelf = filter?.includeSelf ?? fieldPaths.length === 0;
27
+
28
+ return (payload) => {
29
+ for (const fieldPath of fieldPaths) {
30
+ const relationIris = readRelationIris(payload, fieldPath)
31
+ .map((iri) => normalizeIri(topicMapper.toPayloadIri(iri)));
32
+ for (const relationIri of relationIris) {
33
+ const match = findClosestTopic(relationIri, subscribedTopics);
34
+ if (match) return match;
35
+ }
36
+ }
37
+
38
+ if (!includeSelf) return undefined;
39
+
40
+ const payloadIri = payload['@id'];
41
+ if (typeof payloadIri !== 'string') return undefined;
42
+ return findClosestTopic(normalizeIri(topicMapper.toPayloadIri(payloadIri)), subscribedTopics);
43
+ };
44
+ }
45
+
46
+ function readRelationIris(payload: Item, path: string): string[] {
47
+ const value = path
48
+ .split('.')
49
+ .filter(Boolean)
50
+ .reduce<unknown>((current, key) => isIndexable(current) ? current[key] : undefined, payload);
51
+
52
+ return extractIris(value);
53
+ }
54
+
55
+ function extractIris(value: unknown): string[] {
56
+ if (!value) return [];
57
+ if (typeof value === 'string') return value.length > 0 ? [value] : [];
58
+ if (isIndexable(value) && typeof value['@id'] === 'string') return [value['@id']];
59
+ if (Array.isArray(value)) return value.flatMap(extractIris);
60
+ return [];
61
+ }
62
+
63
+ function findClosestTopic(candidate: string, subscribedTopics: string[]): string | undefined {
64
+ let closest: string | undefined;
65
+ for (const topic of subscribedTopics) {
66
+ if (candidate !== topic && !candidate.startsWith(`${topic}/`)) continue;
67
+ if (!closest || topic.length > closest.length) closest = topic;
68
+ }
69
+ return closest;
70
+ }
71
+
72
+ function normalizeIri(iri: string): string {
73
+ return iri.endsWith('/') ? iri.slice(0, -1) : iri;
74
+ }
75
+
76
+ function isRecord(value: unknown): value is Record<string, unknown> {
77
+ return !!value && typeof value === 'object' && !Array.isArray(value);
78
+ }
79
+
80
+ function isIndexable(value: unknown): value is Record<string, unknown> {
81
+ return !!value && typeof value === 'object';
82
+ }
@@ -0,0 +1,39 @@
1
+ export class MercureUrlBuilder {
2
+ /**
3
+ * Builds the Mercure hub URL with one `topic=` parameter per topic.
4
+ * The adapter is responsible for canonicalising topics beforehand.
5
+ */
6
+ build(hubUrl: string, topics: 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 topic of topics) {
13
+ url.searchParams.append('topic', topic);
14
+ }
15
+ return url.toString();
16
+ }
17
+
18
+ partitionTopics(hubUrl: string, topics: Iterable<string>, maxUrlLength: number, allowSplit: boolean): string[][] {
19
+ const sortedTopics = [...new Set(topics)].sort();
20
+ if (sortedTopics.length === 0) return [];
21
+ if (!allowSplit) return [sortedTopics];
22
+
23
+ const partitions: string[][] = [];
24
+ let current: string[] = [];
25
+
26
+ for (const topic of sortedTopics) {
27
+ const candidate = [...current, topic];
28
+ if (current.length > 0 && this.build(hubUrl, new Set(candidate)).length > maxUrlLength) {
29
+ partitions.push(current);
30
+ current = [topic];
31
+ } else {
32
+ current = candidate;
33
+ }
34
+ }
35
+
36
+ if (current.length > 0) partitions.push(current);
37
+ return partitions;
38
+ }
39
+ }