@obsidiane/meridiane 2.0.10 → 2.0.11

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 +3 -3
  4. package/src/README.md +9 -0
  5. package/{tools/app → src/application}/bridge-workflow.js +15 -13
  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/{tools/infra → src/infrastructure}/standalone-workspace.js +31 -29
  29. package/templates/{_lib_template → angular-bridge}/README.md +9 -0
  30. package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +82 -0
  31. package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +30 -0
  32. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/rest/query-builder.ts +10 -10
  33. package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +255 -0
  34. package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +82 -0
  35. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +39 -0
  36. package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +358 -0
  37. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/ref-count-topic.registry.ts +3 -12
  38. package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +76 -0
  39. package/templates/angular-bridge/src/lib/bridge-options.ts +59 -0
  40. package/templates/{_lib_template → angular-bridge}/src/lib/facades/bridge.facade.ts +50 -51
  41. package/templates/{_lib_template → angular-bridge}/src/lib/facades/facade.factory.ts +0 -1
  42. package/templates/angular-bridge/src/lib/facades/facade.interface.ts +13 -0
  43. package/templates/{_lib_template → angular-bridge}/src/lib/facades/resource.facade.ts +24 -31
  44. package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +33 -0
  45. package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +65 -0
  46. package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +37 -0
  47. package/templates/angular-bridge/src/lib/interceptors/scoped.interceptor.ts +9 -0
  48. package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +46 -0
  49. package/templates/{_lib_template → angular-bridge}/src/lib/ports/realtime.port.ts +6 -2
  50. package/templates/{_lib_template → angular-bridge}/src/lib/ports/resource-repository.port.ts +7 -7
  51. package/templates/angular-bridge/src/lib/provide-bridge.ts +98 -0
  52. package/templates/angular-bridge/src/lib/utils/iri.ts +6 -0
  53. package/templates/angular-bridge/src/lib/utils/request-url.ts +31 -0
  54. package/templates/{_lib_template → angular-bridge}/src/public-api.ts +2 -0
  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 → src}/domain/lib-name.js +0 -0
  76. /package/{tools/generator → src/generators}/models/handlebars.js +0 -0
  77. /package/{tools/generator → src/generators}/models/templates/index.hbs +0 -0
  78. /package/{tools/generator → src/generators}/models/templates/model.hbs +0 -0
  79. /package/{tools/infra → src/infrastructure}/json.js +0 -0
  80. /package/{tools/infra → src/infrastructure}/logger.js +0 -0
  81. /package/templates/{_lib_template → angular-bridge}/ng-package.json +0 -0
  82. /package/templates/{_lib_template → angular-bridge}/package.json +0 -0
  83. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/eventsource-wrapper.ts +0 -0
  84. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -0
  85. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge.types.ts +0 -0
  86. /package/templates/{_lib_template → angular-bridge}/src/lib/interceptors/bridge-debug.interceptor.ts +0 -0
  87. /package/templates/{_lib_template → angular-bridge}/src/lib/tokens.ts +0 -0
  88. /package/templates/{_lib_template → angular-bridge}/src/lib/utils/url.ts +0 -0
  89. /package/templates/{_lib_template → angular-bridge}/src/models/index.ts +0 -0
  90. /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.json +0 -0
  91. /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.prod.json +0 -0
@@ -1,806 +0,0 @@
1
- import {Inject, Injectable, OnDestroy, Optional, PLATFORM_ID} from '@angular/core';
2
- import {BehaviorSubject, defer, EMPTY, fromEvent, Observable, of, Subject} from 'rxjs';
3
- import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
4
- import {
5
- API_BASE_URL,
6
- BRIDGE_LOGGER,
7
- BRIDGE_MERCURE_WITH_CREDENTIALS,
8
- MERCURE_CONNECTION_MODE,
9
- MERCURE_HUB_URL,
10
- MERCURE_MAX_URL_LENGTH,
11
- MERCURE_TOPIC_MODE,
12
- } from '../../tokens';
13
- import {
14
- BridgeLogger,
15
- MercureConnectionMode,
16
- MercureTopicMode,
17
- RealtimeDiagnostics,
18
- WatchConnectionOptions,
19
- } from '../../bridge.types';
20
- import {RealtimeEvent, RealtimePort, RealtimeStatus, SubscribeFilter} from '../../ports/realtime.port';
21
- import {EventSourceWrapper} from './eventsource-wrapper';
22
- import {Item} from '../../ports/resource-repository.port';
23
- import {isPlatformBrowser} from '@angular/common';
24
- import {MercureUrlBuilder} from './mercure-url.builder';
25
- import {RefCountTopicRegistry} from './ref-count-topic.registry';
26
- import {MercureTopicMapper} from './mercure-topic.mapper';
27
-
28
- type IncomingMessage = { type: string; data: string; lastEventId?: string };
29
-
30
- type ManagedConnectionScope = 'shared' | 'dedicated';
31
-
32
- type ManagedConnection = {
33
- id: string;
34
- key: string;
35
- scope: ManagedConnectionScope;
36
- groupId?: string;
37
- topics: ReadonlySet<string>;
38
- urlLength: number;
39
- wrapper: EventSourceWrapper;
40
- stop$: Subject<void>;
41
- status: RealtimeStatus;
42
- };
43
-
44
- type SharedSubscriptionClaim = {
45
- id: string;
46
- topics: string[];
47
- topicsKey: string;
48
- stop$: Subject<void>;
49
- released: boolean;
50
- };
51
-
52
- type DedicatedSubscriptionClaim = {
53
- id: string;
54
- groupId: string;
55
- topics: string[];
56
- topicsKey: string;
57
- incoming: Subject<IncomingMessage>;
58
- stop$: Subject<void>;
59
- released: boolean;
60
- };
61
-
62
- @Injectable({providedIn: 'root'})
63
- export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
64
- private readonly connectionMode: MercureConnectionMode;
65
- private readonly maxUrlLength: number;
66
-
67
- private readonly topicsRegistry = new RefCountTopicRegistry();
68
- private readonly urlBuilder: MercureUrlBuilder;
69
- private readonly topicMapper: MercureTopicMapper;
70
-
71
- private readonly sharedConnections = new Map<string, ManagedConnection>();
72
- private readonly connections = new Map<string, ManagedConnection>();
73
- private readonly dedicatedGroups = new Map<string, Set<string>>();
74
- private readonly lastEventIdByKey = new Map<string, string>();
75
- private readonly sharedClaims = new Map<string, SharedSubscriptionClaim>();
76
- private readonly sharedClaimQueueByTopicsKey = new Map<string, string[]>();
77
- private readonly dedicatedClaims = new Map<string, DedicatedSubscriptionClaim>();
78
- private readonly dedicatedClaimQueueByTopicsKey = new Map<string, string[]>();
79
-
80
- private readonly destroy$ = new Subject<void>();
81
- private readonly sharedRebuild$ = new Subject<void>();
82
- private shuttingDown = false;
83
- private sequence = 0;
84
-
85
- private readonly _status$ = new BehaviorSubject<RealtimeStatus>('closed');
86
- private readonly _diagnostics$ = new BehaviorSubject<RealtimeDiagnostics>(emptyDiagnostics('auto', 1900));
87
- private readonly incoming$ = new Subject<IncomingMessage>();
88
-
89
- constructor(
90
- @Inject(API_BASE_URL) private readonly apiBase: string,
91
- @Inject(BRIDGE_MERCURE_WITH_CREDENTIALS) private readonly mercureWithCredentials: boolean,
92
- @Inject(PLATFORM_ID) private readonly platformId: object,
93
- @Optional() @Inject(MERCURE_HUB_URL) private readonly hubUrl?: string,
94
- @Optional() @Inject(MERCURE_TOPIC_MODE) topicMode?: MercureTopicMode,
95
- @Optional() @Inject(MERCURE_CONNECTION_MODE) connectionMode?: MercureConnectionMode | null,
96
- @Optional() @Inject(MERCURE_MAX_URL_LENGTH) maxUrlLength?: number | null,
97
- @Optional() @Inject(BRIDGE_LOGGER) private readonly logger?: BridgeLogger,
98
- ) {
99
- this.connectionMode = connectionMode ?? 'auto';
100
- this.maxUrlLength = normalizeMaxUrlLength(maxUrlLength, 1900);
101
- this.emitDiagnostics();
102
-
103
- this.urlBuilder = new MercureUrlBuilder();
104
- // `topicMode` affects only the `topic=` query param sent to the hub.
105
- // Payload IRIs are always matched using same-origin relative IRIs (`/api/...`) when possible.
106
- this.topicMapper = new MercureTopicMapper(apiBase, topicMode ?? 'url');
107
-
108
- this.sharedRebuild$
109
- .pipe(
110
- auditTime(10),
111
- concatMap(() => this.rebuildSharedConnectionsOnce$()),
112
- )
113
- .subscribe();
114
-
115
- if (isPlatformBrowser(this.platformId)) {
116
- fromEvent<PageTransitionEvent>(window, 'pagehide')
117
- .pipe(takeUntil(this.destroy$))
118
- .subscribe(() => this.shutdownBeforeExit());
119
-
120
- fromEvent<BeforeUnloadEvent>(window, 'beforeunload')
121
- .pipe(takeUntil(this.destroy$))
122
- .subscribe(() => this.shutdownBeforeExit());
123
- }
124
- }
125
-
126
- // ──────────────── API publique ────────────────
127
-
128
- status$(): Observable<RealtimeStatus> {
129
- return this._status$.asObservable();
130
- }
131
-
132
- diagnostics$(): Observable<RealtimeDiagnostics> {
133
- return this._diagnostics$.asObservable();
134
- }
135
-
136
- subscribe$<T>(
137
- iris: string[],
138
- subscribeFilter?: SubscribeFilter,
139
- options?: WatchConnectionOptions
140
- ): Observable<RealtimeEvent<T>> {
141
- return defer(() => {
142
- const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
143
- if (inputIris.length === 0) return EMPTY;
144
-
145
- if (!this.hubUrl) {
146
- this.logger?.debug?.('[Mercure] hubUrl not configured → realtime disabled');
147
- return EMPTY;
148
- }
149
-
150
- const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
151
- const source$ = this.buildIncomingStream$(registeredTopics, options);
152
-
153
- // Matching is done against the payload IRIs (typically "/api/...").
154
- const subscribed = inputIris.map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
155
- const fieldPaths = buildFieldPaths(subscribeFilter);
156
- const includeSelf = resolveIncludeSelf(subscribeFilter, {hasFields: fieldPaths.length > 0});
157
-
158
- return source$.pipe(
159
- map((evt) => this.safeParse(evt.data)),
160
- filter((raw): raw is Item => !!raw && typeof raw === 'object' && !Array.isArray(raw)),
161
- map((payload) => {
162
- const topic = this.matchSubscribedTopic(payload, subscribed, {fieldPaths, includeSelf});
163
- if (!topic) return undefined;
164
-
165
- const rawId = payload?.['@id'];
166
- const iri = typeof rawId === 'string' ? rawId : topic;
167
- return {topic, iri, data: payload as T} as RealtimeEvent<T>;
168
- }),
169
- filter((e): e is RealtimeEvent<T> => !!e),
170
- share()
171
- );
172
- });
173
- }
174
-
175
- subscribeAll$<T>(iris: string[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>> {
176
- return defer(() => {
177
- const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
178
- if (inputIris.length === 0) return EMPTY;
179
-
180
- if (!this.hubUrl) {
181
- this.logger?.debug?.('[Mercure] hubUrl not configured → realtime disabled');
182
- return EMPTY;
183
- }
184
-
185
- const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
186
- const source$ = this.buildIncomingStream$(registeredTopics, options);
187
-
188
- return source$.pipe(
189
- map((evt) => this.safeParse(evt.data)),
190
- filter((raw): raw is Item => !!raw && typeof raw === 'object' && !Array.isArray(raw)),
191
- map((payload: any) => {
192
- const rawId = payload?.['@id'];
193
- const iri = typeof rawId === 'string' ? rawId : '';
194
- return {iri, data: payload as T} as RealtimeEvent<T>;
195
- }),
196
- share()
197
- );
198
- });
199
- }
200
-
201
- unsubscribe(iris: string[]): void {
202
- const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
203
- if (inputIris.length === 0) return;
204
-
205
- const topics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
206
- if (this.releaseOneClaimByTopics(topics)) return;
207
-
208
- // Claims are the source of truth. Never drop shared topics blindly when claims still exist:
209
- // it could disconnect another active consumer.
210
- if (this.sharedClaims.size === 0 && this.dedicatedClaims.size === 0) {
211
- this.topicsRegistry.removeAll(topics);
212
- this.scheduleSharedRebuild();
213
- return;
214
- }
215
-
216
- this.logger?.warn?.('[Mercure] unsubscribe ignored: no matching active claim for topics', {topics});
217
- }
218
-
219
- public shutdownBeforeExit(): void {
220
- if (this.shuttingDown) return;
221
- this.shuttingDown = true;
222
- this.teardownAllConnections();
223
- }
224
-
225
- ngOnDestroy(): void {
226
- this.teardownAllConnections();
227
- this._status$.complete();
228
- this._diagnostics$.complete();
229
- this.incoming$.complete();
230
- this.destroy$.next();
231
- this.destroy$.complete();
232
- this.sharedRebuild$.complete();
233
- }
234
-
235
- // ───────────────── PRIVATE ─────────────────
236
-
237
- private buildIncomingStream$(topics: string[], options?: WatchConnectionOptions): Observable<IncomingMessage> {
238
- return this.shouldUseDedicatedConnection(options)
239
- ? this.subscribeViaDedicatedConnections$(topics)
240
- : this.subscribeViaSharedConnections$(topics);
241
- }
242
-
243
- private shouldUseDedicatedConnection(options?: WatchConnectionOptions): boolean {
244
- if (this.connectionMode === 'single') return true;
245
- return options?.newConnection === true;
246
- }
247
-
248
- private subscribeViaSharedConnections$(topics: string[]): Observable<IncomingMessage> {
249
- return defer(() => {
250
- const uniqueTopics = Array.from(new Set(topics));
251
- if (uniqueTopics.length === 0) return EMPTY;
252
- const claim = this.createSharedClaim(uniqueTopics);
253
-
254
- return this.incoming$.pipe(
255
- takeUntil(claim.stop$),
256
- finalize(() => {
257
- this.releaseSharedClaim(claim.id);
258
- })
259
- );
260
- });
261
- }
262
-
263
- private subscribeViaDedicatedConnections$(topics: string[]): Observable<IncomingMessage> {
264
- return defer(() => {
265
- if (!this.hubUrl) return EMPTY;
266
-
267
- const uniqueTopics = Array.from(new Set(topics));
268
- if (uniqueTopics.length === 0) return EMPTY;
269
-
270
- const groupId = `dedicated-${++this.sequence}`;
271
- const incoming = new Subject<IncomingMessage>();
272
- const claim = this.createDedicatedClaim({groupId, topics: uniqueTopics, incoming});
273
-
274
- this.dedicatedGroups.set(groupId, new Set());
275
-
276
- try {
277
- const shards = this.buildTopicShards(uniqueTopics, {allowSplit: this.connectionMode === 'auto'});
278
-
279
- for (const shard of shards) {
280
- const shardSet = new Set(shard);
281
- const key = this.computeConnectionKey(shardSet, 'dedicated', groupId);
282
- const conn = this.openConnection({key, scope: 'dedicated', groupId, topics: shardSet, target: incoming});
283
- this.dedicatedGroups.get(groupId)?.add(conn.id);
284
- }
285
- } catch (err) {
286
- this.logger?.error?.('[Mercure] dedicated connection setup failed:', err);
287
- this.releaseDedicatedClaim(claim.id);
288
- return EMPTY;
289
- }
290
-
291
- return incoming.asObservable().pipe(
292
- takeUntil(claim.stop$),
293
- finalize(() => {
294
- this.releaseDedicatedClaim(claim.id);
295
- })
296
- );
297
- });
298
- }
299
-
300
- private scheduleSharedRebuild(): void {
301
- if (this.shuttingDown) return;
302
- this.sharedRebuild$.next();
303
- }
304
-
305
- private rebuildSharedConnectionsOnce$(): Observable<void> {
306
- return defer(() => {
307
- if (this.shuttingDown) return of(void 0);
308
-
309
- try {
310
- if (!this.hubUrl || !this.topicsRegistry.hasAny()) {
311
- this.closeAllSharedConnections();
312
- return of(void 0);
313
- }
314
-
315
- const shards = this.buildTopicShards(this.topicsRegistry.snapshot(), {allowSplit: this.connectionMode === 'auto'});
316
- const desired = new Map<string, ReadonlySet<string>>();
317
-
318
- for (const shard of shards) {
319
- const topics = new Set(shard);
320
- const key = this.computeConnectionKey(topics, 'shared');
321
- desired.set(key, topics);
322
- }
323
-
324
- for (const [key, conn] of Array.from(this.sharedConnections.entries())) {
325
- if (!desired.has(key)) {
326
- this.closeConnection(conn.id);
327
- }
328
- }
329
-
330
- for (const [key, topics] of desired.entries()) {
331
- if (this.sharedConnections.has(key)) continue;
332
- const conn = this.openConnection({key, scope: 'shared', topics, target: this.incoming$});
333
- this.sharedConnections.set(key, conn);
334
- }
335
- } catch (err) {
336
- this.logger?.error?.('[Mercure] shared rebuild failed:', err);
337
- }
338
-
339
- return of(void 0);
340
- });
341
- }
342
-
343
- private openConnection(params: {
344
- key: string;
345
- scope: ManagedConnectionScope;
346
- topics: ReadonlySet<string>;
347
- target: Subject<IncomingMessage>;
348
- groupId?: string;
349
- }): ManagedConnection {
350
- if (!this.hubUrl) {
351
- throw new Error('Mercure hub URL is required to open a realtime connection.');
352
- }
353
-
354
- const id = `conn-${++this.sequence}`;
355
- const lastEventId = this.lastEventIdByKey.get(params.key);
356
- const url = this.urlBuilder.build(this.hubUrl, params.topics, lastEventId);
357
-
358
- this.logger?.debug?.('[Mercure] connect', {
359
- mode: this.connectionMode,
360
- scope: params.scope,
361
- key: params.key,
362
- urlLength: url.length,
363
- topics: Array.from(params.topics),
364
- lastEventId,
365
- });
366
-
367
- const wrapper = new EventSourceWrapper(url, {withCredentials: this.mercureWithCredentials}, this.logger);
368
- const stop$ = new Subject<void>();
369
-
370
- const conn: ManagedConnection = {
371
- id,
372
- key: params.key,
373
- scope: params.scope,
374
- groupId: params.groupId,
375
- topics: params.topics,
376
- urlLength: url.length,
377
- wrapper,
378
- stop$,
379
- status: 'connecting',
380
- };
381
-
382
- this.connections.set(conn.id, conn);
383
-
384
- wrapper.status$
385
- .pipe(takeUntil(stop$), takeUntil(this.destroy$))
386
- .subscribe((st) => {
387
- conn.status = st;
388
- this.updateGlobalStatus();
389
- });
390
-
391
- wrapper.events$
392
- .pipe(takeUntil(stop$), takeUntil(this.destroy$))
393
- .subscribe((e) => {
394
- const lastEventIdValue = e.lastEventId;
395
- if (typeof lastEventIdValue === 'string' && lastEventIdValue.length > 0) {
396
- this.lastEventIdByKey.set(params.key, lastEventIdValue);
397
- }
398
- params.target.next({type: e.type, data: e.data, lastEventId: e.lastEventId});
399
- });
400
-
401
- wrapper.open();
402
- this.updateGlobalStatus();
403
-
404
- return conn;
405
- }
406
-
407
- private closeAllSharedConnections(): void {
408
- for (const conn of Array.from(this.sharedConnections.values())) {
409
- this.closeConnection(conn.id);
410
- }
411
- }
412
-
413
- private createSharedClaim(topics: string[]): SharedSubscriptionClaim {
414
- const normalizedTopics = Array.from(new Set(topics));
415
- const claim: SharedSubscriptionClaim = {
416
- id: `shared-claim-${++this.sequence}`,
417
- topics: normalizedTopics,
418
- topicsKey: this.computeTopicsKey(normalizedTopics),
419
- stop$: new Subject<void>(),
420
- released: false,
421
- };
422
-
423
- this.sharedClaims.set(claim.id, claim);
424
- this.queueClaim(this.sharedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
425
- this.topicsRegistry.addAll(claim.topics);
426
- this.scheduleSharedRebuild();
427
-
428
- return claim;
429
- }
430
-
431
- private createDedicatedClaim(params: {
432
- groupId: string;
433
- topics: string[];
434
- incoming: Subject<IncomingMessage>;
435
- }): DedicatedSubscriptionClaim {
436
- const normalizedTopics = Array.from(new Set(params.topics));
437
- const claim: DedicatedSubscriptionClaim = {
438
- id: `dedicated-claim-${++this.sequence}`,
439
- groupId: params.groupId,
440
- topics: normalizedTopics,
441
- topicsKey: this.computeTopicsKey(normalizedTopics),
442
- incoming: params.incoming,
443
- stop$: new Subject<void>(),
444
- released: false,
445
- };
446
-
447
- this.dedicatedClaims.set(claim.id, claim);
448
- this.queueClaim(this.dedicatedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
449
- return claim;
450
- }
451
-
452
- private releaseOneClaimByTopics(topics: string[]): boolean {
453
- const topicsKey = this.computeTopicsKey(topics);
454
- if (!topicsKey) return false;
455
-
456
- if (this.connectionMode === 'single') {
457
- return this.releaseOneDedicatedClaimByTopics(topicsKey) || this.releaseOneSharedClaimByTopics(topicsKey);
458
- }
459
-
460
- return this.releaseOneSharedClaimByTopics(topicsKey) || this.releaseOneDedicatedClaimByTopics(topicsKey);
461
- }
462
-
463
- private releaseOneSharedClaimByTopics(topicsKey: string): boolean {
464
- const queue = this.sharedClaimQueueByTopicsKey.get(topicsKey);
465
- if (!queue || queue.length === 0) return false;
466
-
467
- while (queue.length > 0) {
468
- const claimId = queue.shift();
469
- if (!claimId) continue;
470
- if (this.stopSharedClaim(claimId)) {
471
- if (queue.length === 0) this.sharedClaimQueueByTopicsKey.delete(topicsKey);
472
- return true;
473
- }
474
- }
475
-
476
- this.sharedClaimQueueByTopicsKey.delete(topicsKey);
477
- return false;
478
- }
479
-
480
- private releaseOneDedicatedClaimByTopics(topicsKey: string): boolean {
481
- const queue = this.dedicatedClaimQueueByTopicsKey.get(topicsKey);
482
- if (!queue || queue.length === 0) return false;
483
-
484
- while (queue.length > 0) {
485
- const claimId = queue.shift();
486
- if (!claimId) continue;
487
- if (this.stopDedicatedClaim(claimId)) {
488
- if (queue.length === 0) this.dedicatedClaimQueueByTopicsKey.delete(topicsKey);
489
- return true;
490
- }
491
- }
492
-
493
- this.dedicatedClaimQueueByTopicsKey.delete(topicsKey);
494
- return false;
495
- }
496
-
497
- private stopSharedClaim(claimId: string): boolean {
498
- const claim = this.sharedClaims.get(claimId);
499
- if (!claim || claim.released) return false;
500
- claim.stop$.next();
501
- claim.stop$.complete();
502
- return true;
503
- }
504
-
505
- private stopDedicatedClaim(claimId: string): boolean {
506
- const claim = this.dedicatedClaims.get(claimId);
507
- if (!claim || claim.released) return false;
508
- claim.stop$.next();
509
- claim.stop$.complete();
510
- return true;
511
- }
512
-
513
- private releaseSharedClaim(claimId: string): void {
514
- const claim = this.sharedClaims.get(claimId);
515
- if (!claim || claim.released) return;
516
-
517
- claim.released = true;
518
- this.sharedClaims.delete(claim.id);
519
- this.removeClaimFromQueue(this.sharedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
520
-
521
- this.topicsRegistry.removeAll(claim.topics);
522
- this.scheduleSharedRebuild();
523
- }
524
-
525
- private releaseDedicatedClaim(claimId: string): void {
526
- const claim = this.dedicatedClaims.get(claimId);
527
- if (!claim || claim.released) return;
528
-
529
- claim.released = true;
530
- this.dedicatedClaims.delete(claim.id);
531
- this.removeClaimFromQueue(this.dedicatedClaimQueueByTopicsKey, claim.topicsKey, claim.id);
532
-
533
- this.closeDedicatedGroup(claim.groupId);
534
- claim.incoming.complete();
535
- }
536
-
537
- private queueClaim(queueByKey: Map<string, string[]>, topicsKey: string, claimId: string): void {
538
- const queue = queueByKey.get(topicsKey) ?? [];
539
- queue.push(claimId);
540
- queueByKey.set(topicsKey, queue);
541
- }
542
-
543
- private removeClaimFromQueue(queueByKey: Map<string, string[]>, topicsKey: string, claimId: string): void {
544
- const queue = queueByKey.get(topicsKey);
545
- if (!queue || queue.length === 0) return;
546
- const next = queue.filter((id) => id !== claimId);
547
- if (next.length === 0) {
548
- queueByKey.delete(topicsKey);
549
- return;
550
- }
551
- queueByKey.set(topicsKey, next);
552
- }
553
-
554
- private computeTopicsKey(topics: Iterable<string>): string {
555
- return Array.from(new Set(topics)).sort().join('|');
556
- }
557
-
558
- private closeDedicatedGroup(groupId: string): void {
559
- const connectionIds = this.dedicatedGroups.get(groupId);
560
- if (!connectionIds) return;
561
-
562
- for (const connectionId of Array.from(connectionIds)) {
563
- this.closeConnection(connectionId);
564
- }
565
-
566
- this.dedicatedGroups.delete(groupId);
567
- }
568
-
569
- private closeConnection(connectionId: string): void {
570
- const conn = this.connections.get(connectionId);
571
- if (!conn) return;
572
-
573
- conn.status = 'closed';
574
- conn.wrapper.close();
575
- conn.stop$.next();
576
- conn.stop$.complete();
577
-
578
- this.connections.delete(connectionId);
579
-
580
- if (conn.scope === 'shared') {
581
- this.sharedConnections.delete(conn.key);
582
- }
583
-
584
- if (conn.scope === 'dedicated' && conn.groupId) {
585
- const groupConnections = this.dedicatedGroups.get(conn.groupId);
586
- groupConnections?.delete(conn.id);
587
- if (groupConnections && groupConnections.size === 0) {
588
- this.dedicatedGroups.delete(conn.groupId);
589
- }
590
- this.lastEventIdByKey.delete(conn.key);
591
- }
592
-
593
- this.updateGlobalStatus();
594
- }
595
-
596
- private teardownAllConnections(): void {
597
- for (const claimId of Array.from(this.sharedClaims.keys())) {
598
- this.stopSharedClaim(claimId);
599
- }
600
- for (const claimId of Array.from(this.dedicatedClaims.keys())) {
601
- this.stopDedicatedClaim(claimId);
602
- }
603
-
604
- for (const conn of Array.from(this.connections.values())) {
605
- this.closeConnection(conn.id);
606
- }
607
-
608
- this.sharedClaims.clear();
609
- this.sharedClaimQueueByTopicsKey.clear();
610
- this.dedicatedClaims.clear();
611
- this.dedicatedClaimQueueByTopicsKey.clear();
612
- this.sharedConnections.clear();
613
- this.dedicatedGroups.clear();
614
- this.updateGlobalStatus();
615
- }
616
-
617
- private updateGlobalStatus(): void {
618
- if (this.connections.size === 0) {
619
- this._status$.next('closed');
620
- this.emitDiagnostics();
621
- return;
622
- }
623
-
624
- const statuses = Array.from(this.connections.values()).map((c) => c.status);
625
- if (statuses.includes('connected')) {
626
- this._status$.next('connected');
627
- this.emitDiagnostics();
628
- return;
629
- }
630
-
631
- if (statuses.includes('connecting')) {
632
- this._status$.next('connecting');
633
- this.emitDiagnostics();
634
- return;
635
- }
636
-
637
- this._status$.next('closed');
638
- this.emitDiagnostics();
639
- }
640
-
641
- private emitDiagnostics(): void {
642
- const connections = Array.from(this.connections.values())
643
- .map((conn) => ({
644
- id: conn.id,
645
- scope: conn.scope,
646
- status: conn.status,
647
- topicCount: conn.topics.size,
648
- topics: Array.from(conn.topics).sort(),
649
- urlLength: conn.urlLength,
650
- }))
651
- .sort((a, b) => {
652
- if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
653
- return a.id.localeCompare(b.id);
654
- });
655
-
656
- const sharedConnections = connections.filter((c) => c.scope === 'shared').length;
657
- const dedicatedConnections = connections.length - sharedConnections;
658
-
659
- this._diagnostics$.next({
660
- mode: this.connectionMode,
661
- maxUrlLength: this.maxUrlLength,
662
- totalConnections: connections.length,
663
- sharedConnections,
664
- dedicatedConnections,
665
- connections,
666
- });
667
- }
668
-
669
- private buildTopicShards(topics: Iterable<string>, opts: { allowSplit: boolean }): string[][] {
670
- const uniqueSortedTopics = Array.from(new Set(topics)).sort();
671
- if (uniqueSortedTopics.length === 0) return [];
672
-
673
- if (!opts.allowSplit || !this.hubUrl) {
674
- return [uniqueSortedTopics];
675
- }
676
-
677
- const shards: string[][] = [];
678
- let current: string[] = [];
679
-
680
- for (const topic of uniqueSortedTopics) {
681
- if (current.length === 0) {
682
- current = [topic];
683
- continue;
684
- }
685
-
686
- const candidate = [...current, topic];
687
- const urlLength = this.urlBuilder.build(this.hubUrl, new Set(candidate)).length;
688
- if (urlLength > this.maxUrlLength) {
689
- shards.push(current);
690
- current = [topic];
691
- continue;
692
- }
693
-
694
- current = candidate;
695
- }
696
-
697
- if (current.length > 0) {
698
- shards.push(current);
699
- }
700
-
701
- return shards;
702
- }
703
-
704
- private computeConnectionKey(topics: ReadonlySet<string>, scope: ManagedConnectionScope, groupId?: string): string {
705
- const topicsSorted = Array.from(topics).sort().join('|');
706
- const hub = this.hubUrl ?? '';
707
- const creds = this.mercureWithCredentials ? '1' : '0';
708
- const mode = this.connectionMode;
709
- const group = groupId ?? '';
710
- return `${scope}::${group}::${hub}::${creds}::${mode}::${topicsSorted}`;
711
- }
712
-
713
- private safeParse(raw: string): Item | undefined {
714
- try {
715
- return JSON.parse(raw) as Item;
716
- } catch {
717
- this.logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
718
- return undefined;
719
- }
720
- }
721
-
722
- private extractRelationIris(raw: any, path: string): string[] {
723
- const readPath = (obj: any, dotPath: string): any => {
724
- return dotPath
725
- .split('.')
726
- .filter(Boolean)
727
- .reduce((acc, key) => acc?.[key], obj);
728
- };
729
-
730
- const normalize = (value: any): string[] => {
731
- if (!value) return [];
732
- if (typeof value === 'string') return value.length > 0 ? [value] : [];
733
- if (typeof value === 'object' && typeof value['@id'] === 'string') return [value['@id']];
734
- if (Array.isArray(value)) return value.flatMap(normalize);
735
- return [];
736
- };
737
-
738
- return normalize(readPath(raw, path));
739
- }
740
-
741
- private matchSubscribedTopic(
742
- raw: any,
743
- subscribed: string[],
744
- opts: { fieldPaths: string[]; includeSelf: boolean }
745
- ): string | undefined {
746
- for (const fieldPath of opts.fieldPaths) {
747
- const relIris = this.extractRelationIris(raw, fieldPath).map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
748
- for (const relIri of relIris) {
749
- const match = findMatchingSubscribedTopic(relIri, subscribed);
750
- if (match) return match;
751
- }
752
- }
753
-
754
- if (opts.includeSelf) {
755
- const rawId = raw?.['@id'];
756
- const id = typeof rawId === 'string' ? normalizeIri(this.topicMapper.toPayloadIri(rawId)) : undefined;
757
- if (typeof id === 'string') {
758
- const match = findMatchingSubscribedTopic(id, subscribed);
759
- if (match) return match;
760
- }
761
- }
762
-
763
- return undefined;
764
- }
765
- }
766
-
767
- function normalizeIri(iri: string): string {
768
- return iri.endsWith('/') ? iri.slice(0, -1) : iri;
769
- }
770
-
771
- function findMatchingSubscribedTopic(candidate: string, subscribed: string[]): string | undefined {
772
- let best: string | undefined;
773
- for (const iri of subscribed) {
774
- if (candidate !== iri && !candidate.startsWith(`${iri}/`)) continue;
775
- if (!best || iri.length > best.length) best = iri;
776
- }
777
- return best;
778
- }
779
-
780
- function buildFieldPaths(filter?: SubscribeFilter): string[] {
781
- const fields = filter?.fields?.filter((f): f is string => typeof f === 'string' && f.length > 0) ?? [];
782
- const field = typeof filter?.field === 'string' && filter.field.length > 0 ? [filter.field] : [];
783
- return [...fields, ...field];
784
- }
785
-
786
- function resolveIncludeSelf(filter: SubscribeFilter | undefined, ctx: { hasFields: boolean }): boolean {
787
- if (typeof filter?.includeSelf === 'boolean') return filter.includeSelf;
788
- return !ctx.hasFields;
789
- }
790
-
791
- function normalizeMaxUrlLength(value: number | null | undefined, fallback: number): number {
792
- if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
793
- const rounded = Math.floor(value);
794
- return rounded >= 256 ? rounded : fallback;
795
- }
796
-
797
- function emptyDiagnostics(mode: MercureConnectionMode, maxUrlLength: number): RealtimeDiagnostics {
798
- return {
799
- mode,
800
- maxUrlLength,
801
- totalConnections: 0,
802
- sharedConnections: 0,
803
- dedicatedConnections: 0,
804
- connections: [],
805
- };
806
- }