@obsidiane/meridiane 2.0.4 → 2.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.
- package/README.md +45 -41
- package/package.json +3 -3
- package/templates/_lib_template/README.md +77 -8
- package/templates/_lib_template/package.json +2 -2
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +635 -90
- package/templates/_lib_template/src/lib/bridge.types.ts +37 -0
- package/templates/_lib_template/src/lib/facades/bridge.facade.ts +77 -2
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +2 -1
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +8 -6
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +33 -2
- package/templates/_lib_template/src/lib/provide-bridge.ts +23 -1
- package/templates/_lib_template/src/lib/tokens.ts +5 -1
- package/tools/app/bridge-workflow.js +8 -0
- package/tools/infra/dev-support.js +77 -0
- package/tools/infra/standalone-workspace.js +49 -15
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import {Inject, Injectable, OnDestroy, Optional, PLATFORM_ID} from '@angular/core';
|
|
2
2
|
import {BehaviorSubject, defer, EMPTY, fromEvent, Observable, of, Subject} from 'rxjs';
|
|
3
3
|
import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
API_BASE_URL,
|
|
6
|
+
BRIDGE_LOGGER,
|
|
7
|
+
BRIDGE_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';
|
|
7
21
|
import {EventSourceWrapper} from './eventsource-wrapper';
|
|
8
22
|
import {Item} from '../../ports/resource-repository.port';
|
|
9
23
|
import {isPlatformBrowser} from '@angular/common';
|
|
@@ -11,24 +25,66 @@ import {MercureUrlBuilder} from './mercure-url.builder';
|
|
|
11
25
|
import {RefCountTopicRegistry} from './ref-count-topic.registry';
|
|
12
26
|
import {MercureTopicMapper} from './mercure-topic.mapper';
|
|
13
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
|
+
};
|
|
14
61
|
|
|
15
62
|
@Injectable({providedIn: 'root'})
|
|
16
63
|
export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
private currentKey?: string;
|
|
64
|
+
private readonly connectionMode: MercureConnectionMode;
|
|
65
|
+
private readonly maxUrlLength: number;
|
|
20
66
|
|
|
21
67
|
private readonly topicsRegistry = new RefCountTopicRegistry();
|
|
22
68
|
private readonly urlBuilder: MercureUrlBuilder;
|
|
23
69
|
private readonly topicMapper: MercureTopicMapper;
|
|
24
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
|
+
|
|
25
80
|
private readonly destroy$ = new Subject<void>();
|
|
26
|
-
private
|
|
27
|
-
private readonly rebuild$ = new Subject<void>();
|
|
81
|
+
private readonly sharedRebuild$ = new Subject<void>();
|
|
28
82
|
private shuttingDown = false;
|
|
83
|
+
private sequence = 0;
|
|
29
84
|
|
|
30
85
|
private readonly _status$ = new BehaviorSubject<RealtimeStatus>('closed');
|
|
31
|
-
private readonly
|
|
86
|
+
private readonly _diagnostics$ = new BehaviorSubject<RealtimeDiagnostics>(emptyDiagnostics('auto', 1900));
|
|
87
|
+
private readonly incoming$ = new Subject<IncomingMessage>();
|
|
32
88
|
|
|
33
89
|
constructor(
|
|
34
90
|
@Inject(API_BASE_URL) private readonly apiBase: string,
|
|
@@ -36,17 +92,23 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
36
92
|
@Inject(PLATFORM_ID) private readonly platformId: object,
|
|
37
93
|
@Optional() @Inject(MERCURE_HUB_URL) private readonly hubUrl?: string,
|
|
38
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,
|
|
39
97
|
@Optional() @Inject(BRIDGE_LOGGER) private readonly logger?: BridgeLogger,
|
|
40
98
|
) {
|
|
99
|
+
this.connectionMode = connectionMode ?? 'auto';
|
|
100
|
+
this.maxUrlLength = normalizeMaxUrlLength(maxUrlLength, 1900);
|
|
101
|
+
this.emitDiagnostics();
|
|
102
|
+
|
|
41
103
|
this.urlBuilder = new MercureUrlBuilder();
|
|
42
104
|
// `topicMode` affects only the `topic=` query param sent to the hub.
|
|
43
105
|
// Payload IRIs are always matched using same-origin relative IRIs (`/api/...`) when possible.
|
|
44
106
|
this.topicMapper = new MercureTopicMapper(apiBase, topicMode ?? 'url');
|
|
45
107
|
|
|
46
|
-
this.
|
|
108
|
+
this.sharedRebuild$
|
|
47
109
|
.pipe(
|
|
48
110
|
auditTime(10),
|
|
49
|
-
concatMap(() => this.
|
|
111
|
+
concatMap(() => this.rebuildSharedConnectionsOnce$()),
|
|
50
112
|
)
|
|
51
113
|
.subscribe();
|
|
52
114
|
|
|
@@ -67,8 +129,15 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
67
129
|
return this._status$.asObservable();
|
|
68
130
|
}
|
|
69
131
|
|
|
132
|
+
diagnostics$(): Observable<RealtimeDiagnostics> {
|
|
133
|
+
return this._diagnostics$.asObservable();
|
|
134
|
+
}
|
|
70
135
|
|
|
71
|
-
subscribe$<T>(
|
|
136
|
+
subscribe$<T>(
|
|
137
|
+
iris: string[],
|
|
138
|
+
subscribeFilter?: SubscribeFilter,
|
|
139
|
+
options?: WatchConnectionOptions
|
|
140
|
+
): Observable<RealtimeEvent<T>> {
|
|
72
141
|
return defer(() => {
|
|
73
142
|
const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
74
143
|
if (inputIris.length === 0) return EMPTY;
|
|
@@ -78,34 +147,51 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
78
147
|
return EMPTY;
|
|
79
148
|
}
|
|
80
149
|
|
|
81
|
-
// Canonicalise topics (ref-count + URL) to avoid duplicates like:
|
|
82
|
-
// - "/api/conversations/1" and "http://localhost:8000/api/conversations/1"
|
|
83
150
|
const registeredTopics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
|
|
84
|
-
|
|
85
|
-
this.topicsRegistry.addAll(registeredTopics);
|
|
86
|
-
this.scheduleRebuild();
|
|
151
|
+
const source$ = this.buildIncomingStream$(registeredTopics, options);
|
|
87
152
|
|
|
88
153
|
// Matching is done against the payload IRIs (typically "/api/...").
|
|
89
154
|
const subscribed = inputIris.map((i) => normalizeIri(this.topicMapper.toPayloadIri(i)));
|
|
90
|
-
const
|
|
155
|
+
const fieldPaths = buildFieldPaths(subscribeFilter);
|
|
156
|
+
const includeSelf = resolveIncludeSelf(subscribeFilter, {hasFields: fieldPaths.length > 0});
|
|
91
157
|
|
|
92
|
-
return
|
|
158
|
+
return source$.pipe(
|
|
93
159
|
map((evt) => this.safeParse(evt.data)),
|
|
94
|
-
filter((raw): raw is Item => !!raw),
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const id = typeof rawId === 'string' ? normalizeIri(this.topicMapper.toPayloadIri(rawId)) : undefined;
|
|
103
|
-
return typeof id === 'string' && matchesAnySubscribed(id, subscribed);
|
|
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>;
|
|
104
168
|
}),
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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>;
|
|
109
195
|
}),
|
|
110
196
|
share()
|
|
111
197
|
);
|
|
@@ -115,113 +201,519 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
115
201
|
unsubscribe(iris: string[]): void {
|
|
116
202
|
const inputIris = iris.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
117
203
|
if (inputIris.length === 0) return;
|
|
204
|
+
|
|
118
205
|
const topics = Array.from(new Set(inputIris.map((i) => this.topicMapper.toTopic(i))));
|
|
119
|
-
this.
|
|
120
|
-
|
|
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});
|
|
121
217
|
}
|
|
122
218
|
|
|
123
219
|
public shutdownBeforeExit(): void {
|
|
124
220
|
if (this.shuttingDown) return;
|
|
125
221
|
this.shuttingDown = true;
|
|
126
|
-
this.
|
|
222
|
+
this.teardownAllConnections();
|
|
127
223
|
}
|
|
128
224
|
|
|
129
225
|
ngOnDestroy(): void {
|
|
130
|
-
this.
|
|
226
|
+
this.teardownAllConnections();
|
|
131
227
|
this._status$.complete();
|
|
228
|
+
this._diagnostics$.complete();
|
|
132
229
|
this.incoming$.complete();
|
|
133
230
|
this.destroy$.next();
|
|
134
231
|
this.destroy$.complete();
|
|
135
|
-
this.
|
|
232
|
+
this.sharedRebuild$.complete();
|
|
136
233
|
}
|
|
137
234
|
|
|
138
235
|
// ───────────────── PRIVATE ─────────────────
|
|
139
236
|
|
|
140
|
-
private
|
|
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 {
|
|
141
301
|
if (this.shuttingDown) return;
|
|
142
|
-
this.
|
|
302
|
+
this.sharedRebuild$.next();
|
|
143
303
|
}
|
|
144
304
|
|
|
145
|
-
private
|
|
305
|
+
private rebuildSharedConnectionsOnce$(): Observable<void> {
|
|
146
306
|
return defer(() => {
|
|
147
307
|
if (this.shuttingDown) return of(void 0);
|
|
148
308
|
|
|
149
309
|
try {
|
|
150
|
-
if (!this.hubUrl) {
|
|
151
|
-
this.
|
|
152
|
-
this._status$.next('closed');
|
|
310
|
+
if (!this.hubUrl || !this.topicsRegistry.hasAny()) {
|
|
311
|
+
this.closeAllSharedConnections();
|
|
153
312
|
return of(void 0);
|
|
154
313
|
}
|
|
155
314
|
|
|
156
|
-
const
|
|
157
|
-
const
|
|
315
|
+
const shards = this.buildTopicShards(this.topicsRegistry.snapshot(), {allowSplit: this.connectionMode === 'auto'});
|
|
316
|
+
const desired = new Map<string, ReadonlySet<string>>();
|
|
158
317
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
return of(void 0);
|
|
318
|
+
for (const shard of shards) {
|
|
319
|
+
const topics = new Set(shard);
|
|
320
|
+
const key = this.computeConnectionKey(topics, 'shared');
|
|
321
|
+
desired.set(key, topics);
|
|
164
322
|
}
|
|
165
323
|
|
|
166
|
-
|
|
167
|
-
|
|
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.withCredentialsDefault}, 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);
|
|
168
397
|
}
|
|
398
|
+
params.target.next({type: e.type, data: e.data, lastEventId: e.lastEventId});
|
|
399
|
+
});
|
|
169
400
|
|
|
170
|
-
|
|
401
|
+
wrapper.open();
|
|
402
|
+
this.updateGlobalStatus();
|
|
171
403
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.es = new EventSourceWrapper(url, {withCredentials: this.withCredentialsDefault}, this.logger);
|
|
404
|
+
return conn;
|
|
405
|
+
}
|
|
175
406
|
|
|
176
|
-
|
|
407
|
+
private closeAllSharedConnections(): void {
|
|
408
|
+
for (const conn of Array.from(this.sharedConnections.values())) {
|
|
409
|
+
this.closeConnection(conn.id);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
177
412
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
};
|
|
181
422
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.incoming$.next(e);
|
|
187
|
-
});
|
|
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();
|
|
188
427
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
473
|
}
|
|
474
|
+
}
|
|
197
475
|
|
|
198
|
-
|
|
199
|
-
|
|
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('|');
|
|
200
556
|
}
|
|
201
557
|
|
|
202
|
-
private
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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);
|
|
207
567
|
}
|
|
208
568
|
|
|
209
|
-
private
|
|
210
|
-
|
|
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')) {
|
|
211
626
|
this._status$.next('connected');
|
|
627
|
+
this.emitDiagnostics();
|
|
212
628
|
return;
|
|
213
629
|
}
|
|
214
|
-
|
|
630
|
+
|
|
631
|
+
if (statuses.includes('connecting')) {
|
|
215
632
|
this._status$.next('connecting');
|
|
633
|
+
this.emitDiagnostics();
|
|
216
634
|
return;
|
|
217
635
|
}
|
|
218
|
-
|
|
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.withCredentialsDefault ? '1' : '0';
|
|
708
|
+
const mode = this.connectionMode;
|
|
709
|
+
const group = groupId ?? '';
|
|
710
|
+
return `${scope}::${group}::${hub}::${creds}::${mode}::${topicsSorted}`;
|
|
219
711
|
}
|
|
220
712
|
|
|
221
713
|
private safeParse(raw: string): Item | undefined {
|
|
222
714
|
try {
|
|
223
715
|
return JSON.parse(raw) as Item;
|
|
224
|
-
} catch
|
|
716
|
+
} catch {
|
|
225
717
|
this.logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
|
|
226
718
|
return undefined;
|
|
227
719
|
}
|
|
@@ -246,16 +738,69 @@ export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
|
246
738
|
return normalize(readPath(raw, path));
|
|
247
739
|
}
|
|
248
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
|
+
}
|
|
249
765
|
}
|
|
250
766
|
|
|
251
767
|
function normalizeIri(iri: string): string {
|
|
252
768
|
return iri.endsWith('/') ? iri.slice(0, -1) : iri;
|
|
253
769
|
}
|
|
254
770
|
|
|
255
|
-
function
|
|
771
|
+
function findMatchingSubscribedTopic(candidate: string, subscribed: string[]): string | undefined {
|
|
772
|
+
let best: string | undefined;
|
|
256
773
|
for (const iri of subscribed) {
|
|
257
|
-
if (candidate
|
|
258
|
-
if (
|
|
774
|
+
if (candidate !== iri && !candidate.startsWith(`${iri}/`)) continue;
|
|
775
|
+
if (!best || iri.length > best.length) best = iri;
|
|
259
776
|
}
|
|
260
|
-
return
|
|
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
|
+
};
|
|
261
806
|
}
|