@obsidiane/meridiane 2.0.7 → 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 +22 -4
  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 +52 -53
  41. package/templates/{_lib_template → angular-bridge}/src/lib/facades/facade.factory.ts +3 -4
  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/{_lib_template → angular-bridge}/src/lib/tokens.ts +9 -3
  53. package/templates/angular-bridge/src/lib/utils/iri.ts +6 -0
  54. package/templates/angular-bridge/src/lib/utils/request-url.ts +31 -0
  55. package/templates/{_lib_template → angular-bridge}/src/public-api.ts +2 -0
  56. package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +0 -84
  57. package/templates/_lib_template/src/lib/bridge/rest/http-request.options.ts +0 -21
  58. package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +0 -17
  59. package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +0 -806
  60. package/templates/_lib_template/src/lib/facades/facade.interface.ts +0 -31
  61. package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +0 -53
  62. package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +0 -49
  63. package/templates/_lib_template/src/lib/interceptors/singleflight.interceptor.ts +0 -55
  64. package/templates/_lib_template/src/lib/provide-bridge.ts +0 -170
  65. package/tools/build.js +0 -1
  66. package/tools/dev.js +0 -1
  67. package/tools/domain/paths.js +0 -10
  68. package/tools/generate.js +0 -1
  69. package/tools/generator/models/oas-contract.js +0 -159
  70. package/tools/generator/models/openapi-to-models.js +0 -240
  71. package/tools/generator/models/type-resolver.js +0 -107
  72. package/tools/generator/models/utils.js +0 -62
  73. package/tools/infra/dev-support.js +0 -189
  74. package/tools/infra/generate/bridge.js +0 -212
  75. package/tools/infra/spec-loader.js +0 -59
  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/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
@@ -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
+ }
@@ -0,0 +1,358 @@
1
+ import {inject, Injectable, OnDestroy, PLATFORM_ID} from '@angular/core';
2
+ import {defer, EMPTY, fromEvent, Observable, Subject} from 'rxjs';
3
+ import {auditTime, filter, finalize, map, 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
+ RealtimeDiagnostics,
15
+ WatchConnectionOptions,
16
+ } from '../../bridge.types';
17
+ import {RealtimeDiagnosticsPort, RealtimeEvent, RealtimeStatus, SubscribeFilter} from '../../ports/realtime.port';
18
+ import {Iri, Item} from '../../ports/resource-repository.port';
19
+ import {isPlatformBrowser} from '@angular/common';
20
+ import {RefCountTopicRegistry} from './ref-count-topic.registry';
21
+ import {MercureTopicMapper} from './mercure-topic.mapper';
22
+ import {SubscriptionClaims, topicsKey} from './subscription-claims';
23
+ import {createPayloadTopicMatcher, parseMercurePayload} from './mercure-payload';
24
+ import {toIriList} from '../../utils/iri';
25
+ import {IncomingMessage, MercureConnectionManager} from './mercure-connections';
26
+
27
+ type SharedSubscriptionClaim = {
28
+ id: string;
29
+ topics: string[];
30
+ topicsKey: string;
31
+ stop$: Subject<void>;
32
+ };
33
+
34
+ type DedicatedSubscriptionClaim = {
35
+ id: string;
36
+ groupId: string;
37
+ topics: string[];
38
+ topicsKey: string;
39
+ incoming: Subject<IncomingMessage>;
40
+ stop$: Subject<void>;
41
+ };
42
+
43
+ type PreparedSubscription = {
44
+ iris: string[];
45
+ source$: Observable<IncomingMessage>;
46
+ };
47
+
48
+ @Injectable({providedIn: 'root'})
49
+ export class MercureRealtimeAdapter implements RealtimeDiagnosticsPort, OnDestroy {
50
+ private readonly apiBase = inject(API_BASE_URL);
51
+ private readonly withCredentials = inject(BRIDGE_MERCURE_WITH_CREDENTIALS);
52
+ private readonly platformId = inject(PLATFORM_ID);
53
+ private readonly hubUrl = inject(MERCURE_HUB_URL, {optional: true}) ?? undefined;
54
+ private readonly topicMode = inject(MERCURE_TOPIC_MODE, {optional: true}) ?? 'url';
55
+ private readonly connectionMode = inject(MERCURE_CONNECTION_MODE, {optional: true}) ?? 'auto';
56
+ private readonly maxUrlLength = normalizeMaxUrlLength(
57
+ inject(MERCURE_MAX_URL_LENGTH, {optional: true}),
58
+ 1900
59
+ );
60
+ private readonly logger = inject(BRIDGE_LOGGER, {optional: true}) ?? undefined;
61
+
62
+ private readonly destroy$ = new Subject<void>();
63
+ private readonly sharedRebuild$ = new Subject<void>();
64
+ private readonly incoming$ = new Subject<IncomingMessage>();
65
+ private shuttingDown = false;
66
+ private sequence = 0;
67
+
68
+ private readonly topicsRegistry = new RefCountTopicRegistry();
69
+ private readonly topicMapper = new MercureTopicMapper(this.apiBase, this.topicMode);
70
+ private readonly connectionManager = new MercureConnectionManager({
71
+ hubUrl: this.hubUrl,
72
+ withCredentials: this.withCredentials,
73
+ mode: this.connectionMode,
74
+ maxUrlLength: this.maxUrlLength,
75
+ logger: this.logger,
76
+ destroy$: this.destroy$,
77
+ nextId: (prefix) => `${prefix}-${++this.sequence}`,
78
+ });
79
+ private readonly sharedClaims = new SubscriptionClaims<SharedSubscriptionClaim>();
80
+ private readonly dedicatedClaims = new SubscriptionClaims<DedicatedSubscriptionClaim>();
81
+
82
+ constructor() {
83
+ this.sharedRebuild$.pipe(auditTime(10)).subscribe(() => this.rebuildSharedConnections());
84
+
85
+ if (isPlatformBrowser(this.platformId)) {
86
+ fromEvent<PageTransitionEvent>(window, 'pagehide')
87
+ .pipe(takeUntil(this.destroy$))
88
+ .subscribe(() => this.shutdownBeforeExit());
89
+
90
+ fromEvent<BeforeUnloadEvent>(window, 'beforeunload')
91
+ .pipe(takeUntil(this.destroy$))
92
+ .subscribe(() => this.shutdownBeforeExit());
93
+ }
94
+ }
95
+
96
+ status$(): Observable<RealtimeStatus> {
97
+ return this.connectionManager.status$;
98
+ }
99
+
100
+ diagnostics$(): Observable<RealtimeDiagnostics> {
101
+ return this.connectionManager.diagnostics$;
102
+ }
103
+
104
+ subscribe$<T>(
105
+ iris: Iri[],
106
+ subscribeFilter?: SubscribeFilter,
107
+ options?: WatchConnectionOptions
108
+ ): Observable<RealtimeEvent<T>> {
109
+ return defer(() => {
110
+ const subscription = this.prepareSubscription(iris, options);
111
+ if (!subscription) return EMPTY;
112
+
113
+ const matchTopic = createPayloadTopicMatcher(
114
+ subscription.iris,
115
+ subscribeFilter,
116
+ this.topicMapper
117
+ );
118
+
119
+ return this.payloads$(subscription.source$).pipe(
120
+ map((payload) => {
121
+ const topic = matchTopic(payload);
122
+ if (!topic) return undefined;
123
+
124
+ const rawId = payload?.['@id'];
125
+ const iri = typeof rawId === 'string' ? rawId : topic;
126
+ return {topic, iri, data: payload as T} as RealtimeEvent<T>;
127
+ }),
128
+ filter((event): event is RealtimeEvent<T> => event !== undefined)
129
+ );
130
+ });
131
+ }
132
+
133
+ subscribeAll$<T>(iris: Iri[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>> {
134
+ return defer(() => {
135
+ const subscription = this.prepareSubscription(iris, options);
136
+ if (!subscription) return EMPTY;
137
+
138
+ return this.payloads$(subscription.source$).pipe(
139
+ map((payload) => {
140
+ const rawId = payload['@id'];
141
+ const iri = typeof rawId === 'string' ? rawId : '';
142
+ return {iri, data: payload as T} as RealtimeEvent<T>;
143
+ })
144
+ );
145
+ });
146
+ }
147
+
148
+ unsubscribe(iris: Iri[]): void {
149
+ const inputIris = toIriList(iris);
150
+ if (inputIris.length === 0) return;
151
+
152
+ const topics = uniqueStrings(inputIris.map((iri) => this.topicMapper.toTopic(iri)));
153
+ if (this.releaseOneClaimByTopics(topics)) return;
154
+
155
+ // Claims are the source of truth. Never drop shared topics blindly when claims still exist:
156
+ // it could disconnect another active consumer.
157
+ if (this.sharedClaims.size === 0 && this.dedicatedClaims.size === 0) {
158
+ this.topicsRegistry.removeAll(topics);
159
+ this.scheduleSharedRebuild();
160
+ return;
161
+ }
162
+
163
+ this.logger?.warn?.('[Mercure] unsubscribe ignored: no matching active claim for topics', {topics});
164
+ }
165
+
166
+ shutdownBeforeExit(): void {
167
+ if (this.shuttingDown) return;
168
+ this.shuttingDown = true;
169
+ this.teardownAllConnections();
170
+ }
171
+
172
+ ngOnDestroy(): void {
173
+ this.teardownAllConnections();
174
+ this.connectionManager.complete();
175
+ this.incoming$.complete();
176
+ this.destroy$.next();
177
+ this.destroy$.complete();
178
+ this.sharedRebuild$.complete();
179
+ }
180
+
181
+ private prepareSubscription(
182
+ iris: Iri[],
183
+ options?: WatchConnectionOptions
184
+ ): PreparedSubscription | undefined {
185
+ const inputIris = toIriList(iris);
186
+ if (inputIris.length === 0) return undefined;
187
+
188
+ if (!this.hubUrl) {
189
+ this.logger?.debug?.('[Mercure] hubUrl not configured → realtime disabled');
190
+ return undefined;
191
+ }
192
+
193
+ const topics = uniqueStrings(inputIris.map((iri) => this.topicMapper.toTopic(iri)));
194
+ return {
195
+ iris: inputIris,
196
+ source$: this.buildIncomingStream$(topics, options),
197
+ };
198
+ }
199
+
200
+ private payloads$(source$: Observable<IncomingMessage>): Observable<Item> {
201
+ return source$.pipe(
202
+ map((event) => parseMercurePayload(event.data, this.logger)),
203
+ filter((payload): payload is Item => payload !== undefined)
204
+ );
205
+ }
206
+
207
+ private buildIncomingStream$(topics: string[], options?: WatchConnectionOptions): Observable<IncomingMessage> {
208
+ const useDedicatedConnection = this.connectionMode === 'single' || options?.newConnection === true;
209
+ return useDedicatedConnection
210
+ ? this.subscribeViaDedicatedConnections$(topics)
211
+ : this.subscribeViaSharedConnections$(topics);
212
+ }
213
+
214
+ private subscribeViaSharedConnections$(topics: string[]): Observable<IncomingMessage> {
215
+ return defer(() => {
216
+ const uniqueTopics = uniqueStrings(topics);
217
+ if (uniqueTopics.length === 0) return EMPTY;
218
+ const claim = this.createSharedClaim(uniqueTopics);
219
+
220
+ return this.incoming$.pipe(
221
+ takeUntil(claim.stop$),
222
+ finalize(() => {
223
+ this.releaseSharedClaim(claim.id);
224
+ })
225
+ );
226
+ });
227
+ }
228
+
229
+ private subscribeViaDedicatedConnections$(topics: string[]): Observable<IncomingMessage> {
230
+ return defer(() => {
231
+ if (!this.hubUrl) return EMPTY;
232
+
233
+ const uniqueTopics = uniqueStrings(topics);
234
+ if (uniqueTopics.length === 0) return EMPTY;
235
+
236
+ const groupId = `dedicated-${++this.sequence}`;
237
+ const incoming = new Subject<IncomingMessage>();
238
+ const claim = this.createDedicatedClaim({groupId, topics: uniqueTopics, incoming});
239
+
240
+ try {
241
+ this.connectionManager.openDedicated(groupId, uniqueTopics, incoming);
242
+ } catch (error) {
243
+ this.logger?.error?.('[Mercure] dedicated connection setup failed:', error);
244
+ this.releaseDedicatedClaim(claim.id);
245
+ return EMPTY;
246
+ }
247
+
248
+ return incoming.asObservable().pipe(
249
+ takeUntil(claim.stop$),
250
+ finalize(() => {
251
+ this.releaseDedicatedClaim(claim.id);
252
+ })
253
+ );
254
+ });
255
+ }
256
+
257
+ private scheduleSharedRebuild(): void {
258
+ if (this.shuttingDown) return;
259
+ this.sharedRebuild$.next();
260
+ }
261
+
262
+ private rebuildSharedConnections(): void {
263
+ if (this.shuttingDown) return;
264
+
265
+ try {
266
+ if (!this.hubUrl || !this.topicsRegistry.hasAny()) {
267
+ this.connectionManager.closeShared();
268
+ return;
269
+ }
270
+
271
+ this.connectionManager.reconcileShared(this.topicsRegistry.snapshot(), this.incoming$);
272
+ } catch (error) {
273
+ this.logger?.error?.('[Mercure] shared rebuild failed:', error);
274
+ }
275
+ }
276
+
277
+ private createSharedClaim(topics: string[]): SharedSubscriptionClaim {
278
+ const normalizedTopics = uniqueStrings(topics);
279
+ const claim: SharedSubscriptionClaim = {
280
+ id: `shared-claim-${++this.sequence}`,
281
+ topics: normalizedTopics,
282
+ topicsKey: topicsKey(normalizedTopics),
283
+ stop$: new Subject<void>(),
284
+ };
285
+
286
+ this.sharedClaims.add(claim);
287
+ this.topicsRegistry.addAll(claim.topics);
288
+ this.scheduleSharedRebuild();
289
+
290
+ return claim;
291
+ }
292
+
293
+ private createDedicatedClaim(params: {
294
+ groupId: string;
295
+ topics: string[];
296
+ incoming: Subject<IncomingMessage>;
297
+ }): DedicatedSubscriptionClaim {
298
+ const normalizedTopics = uniqueStrings(params.topics);
299
+ const claim: DedicatedSubscriptionClaim = {
300
+ id: `dedicated-claim-${++this.sequence}`,
301
+ groupId: params.groupId,
302
+ topics: normalizedTopics,
303
+ topicsKey: topicsKey(normalizedTopics),
304
+ incoming: params.incoming,
305
+ stop$: new Subject<void>(),
306
+ };
307
+
308
+ this.dedicatedClaims.add(claim);
309
+ return claim;
310
+ }
311
+
312
+ private releaseOneClaimByTopics(topics: string[]): boolean {
313
+ const key = topicsKey(topics);
314
+ if (!key) return false;
315
+
316
+ if (this.connectionMode === 'single') {
317
+ return this.dedicatedClaims.stopNext(key) || this.sharedClaims.stopNext(key);
318
+ }
319
+
320
+ return this.sharedClaims.stopNext(key) || this.dedicatedClaims.stopNext(key);
321
+ }
322
+
323
+ private releaseSharedClaim(claimId: string): void {
324
+ const claim = this.sharedClaims.release(claimId);
325
+ if (!claim) return;
326
+
327
+ this.topicsRegistry.removeAll(claim.topics);
328
+ this.scheduleSharedRebuild();
329
+ }
330
+
331
+ private releaseDedicatedClaim(claimId: string): void {
332
+ const claim = this.dedicatedClaims.release(claimId);
333
+ if (!claim) return;
334
+
335
+ this.connectionManager.closeDedicated(claim.groupId);
336
+ claim.incoming.complete();
337
+ }
338
+
339
+ private teardownAllConnections(): void {
340
+ this.shuttingDown = true;
341
+ this.sharedClaims.stopAll();
342
+ this.dedicatedClaims.stopAll();
343
+
344
+ this.sharedClaims.clear();
345
+ this.dedicatedClaims.clear();
346
+ this.connectionManager.closeAll();
347
+ }
348
+ }
349
+
350
+ function normalizeMaxUrlLength(value: number | null | undefined, fallback: number): number {
351
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
352
+ const rounded = Math.floor(value);
353
+ return rounded >= 256 ? rounded : fallback;
354
+ }
355
+
356
+ function uniqueStrings(values: Iterable<string>): string[] {
357
+ return [...new Set(values)];
358
+ }
@@ -1,5 +1,4 @@
1
1
  export class RefCountTopicRegistry {
2
- private readonly topics = new Set<string>();
3
2
  private readonly refCounts = new Map<string, number>();
4
3
 
5
4
  /**
@@ -8,9 +7,7 @@ export class RefCountTopicRegistry {
8
7
  */
9
8
  addAll(topics: Iterable<string>): void {
10
9
  for (const topic of topics) {
11
- const next = (this.refCounts.get(topic) ?? 0) + 1;
12
- this.refCounts.set(topic, next);
13
- this.topics.add(topic);
10
+ this.refCounts.set(topic, (this.refCounts.get(topic) ?? 0) + 1);
14
11
  }
15
12
  }
16
13
 
@@ -23,7 +20,6 @@ export class RefCountTopicRegistry {
23
20
  const next = (this.refCounts.get(topic) ?? 0) - 1;
24
21
  if (next <= 0) {
25
22
  this.refCounts.delete(topic);
26
- this.topics.delete(topic);
27
23
  } else {
28
24
  this.refCounts.set(topic, next);
29
25
  }
@@ -31,15 +27,10 @@ export class RefCountTopicRegistry {
31
27
  }
32
28
 
33
29
  hasAny(): boolean {
34
- return this.topics.size > 0;
30
+ return this.refCounts.size > 0;
35
31
  }
36
32
 
37
33
  snapshot(): ReadonlySet<string> {
38
- return new Set(this.topics);
39
- }
40
-
41
- computeKey(hubUrl: string, credentialsOn: boolean): string {
42
- const topicsSorted = Array.from(this.topics).sort().join('|');
43
- return `${hubUrl}::${credentialsOn ? '1' : '0'}::${topicsSorted}`;
34
+ return new Set(this.refCounts.keys());
44
35
  }
45
36
  }
@@ -0,0 +1,76 @@
1
+ import {Subject} from 'rxjs';
2
+
3
+ export interface SubscriptionClaim {
4
+ id: string;
5
+ topicsKey: string;
6
+ stop$: Subject<void>;
7
+ }
8
+
9
+ /** Tracks active subscriptions and preserves FIFO `unsubscribe(topics)` semantics. */
10
+ export class SubscriptionClaims<T extends SubscriptionClaim> {
11
+ private readonly claims = new Map<string, T>();
12
+ private readonly queueByTopics = new Map<string, string[]>();
13
+
14
+ get size(): number {
15
+ return this.claims.size;
16
+ }
17
+
18
+ add(claim: T): void {
19
+ this.claims.set(claim.id, claim);
20
+ const queue = this.queueByTopics.get(claim.topicsKey) ?? [];
21
+ queue.push(claim.id);
22
+ this.queueByTopics.set(claim.topicsKey, queue);
23
+ }
24
+
25
+ release(claimId: string): T | undefined {
26
+ const claim = this.claims.get(claimId);
27
+ if (!claim) return undefined;
28
+
29
+ this.claims.delete(claimId);
30
+ this.removeFromQueue(claim);
31
+ return claim;
32
+ }
33
+
34
+ stopNext(topicsKey: string): boolean {
35
+ const queue = this.queueByTopics.get(topicsKey);
36
+ while (queue?.length) {
37
+ const claimId = queue[0];
38
+ if (this.stop(claimId)) return true;
39
+ queue.shift();
40
+ }
41
+
42
+ this.queueByTopics.delete(topicsKey);
43
+ return false;
44
+ }
45
+
46
+ stopAll(): void {
47
+ for (const claimId of [...this.claims.keys()]) this.stop(claimId);
48
+ }
49
+
50
+ clear(): void {
51
+ this.claims.clear();
52
+ this.queueByTopics.clear();
53
+ }
54
+
55
+ private stop(claimId: string): boolean {
56
+ const claim = this.claims.get(claimId);
57
+ if (!claim) return false;
58
+
59
+ claim.stop$.next();
60
+ claim.stop$.complete();
61
+ return true;
62
+ }
63
+
64
+ private removeFromQueue(claim: T): void {
65
+ const queue = this.queueByTopics.get(claim.topicsKey);
66
+ if (!queue) return;
67
+
68
+ const remaining = queue.filter((claimId) => claimId !== claim.id);
69
+ if (remaining.length > 0) this.queueByTopics.set(claim.topicsKey, remaining);
70
+ else this.queueByTopics.delete(claim.topicsKey);
71
+ }
72
+ }
73
+
74
+ export function topicsKey(topics: Iterable<string>): string {
75
+ return [...new Set(topics)].sort().join('|');
76
+ }
@@ -0,0 +1,59 @@
1
+ import {HttpInterceptorFn} from '@angular/common/http';
2
+ import {BridgeDefaults, MercureConnectionMode, MercureTopicMode} from './bridge.types';
3
+
4
+ export type BridgeAuth =
5
+ | string
6
+ | {type: 'bearer'; token: string}
7
+ | {type: 'bearer'; getToken: () => string | undefined | Promise<string | undefined>}
8
+ | HttpInterceptorFn;
9
+
10
+ export interface BridgeMercureOptions {
11
+ hubUrl?: string;
12
+ init?: RequestInit;
13
+ topicMode?: MercureTopicMode;
14
+ /**
15
+ * Connection strategy for SSE subscriptions.
16
+ * - `single`: one SSE connection per watch subscription
17
+ * - `auto` (default): shared connections with automatic URL-length sharding
18
+ */
19
+ connectionMode?: MercureConnectionMode;
20
+ /**
21
+ * Maximum Mercure URL length before auto-sharding topics into multiple connections.
22
+ * Used only when `connectionMode: 'auto'`. Default: `1900`.
23
+ */
24
+ maxUrlLength?: number;
25
+ }
26
+
27
+ export interface BridgeHttpOptions {
28
+ /**
29
+ * Enables cookies / credentials on HTTP requests by default.
30
+ *
31
+ * When omitted, the bridge preserves the historical behavior and derives the
32
+ * HTTP value from `mercure.init.credentials`.
33
+ */
34
+ withCredentials?: boolean;
35
+ }
36
+
37
+ export interface BridgeOptions {
38
+ /** Base URL of the API (e.g. `http://localhost:8000`). */
39
+ baseUrl: string;
40
+ /** Auth strategy used to attach an Authorization header. */
41
+ auth?: BridgeAuth;
42
+ /** Default HTTP transport options. */
43
+ http?: BridgeHttpOptions;
44
+ /** Use `topicMode` to control the `topic=` values sent to the hub. */
45
+ mercure?: BridgeMercureOptions;
46
+ /** Default HTTP behaviour (headers, timeout, retries). */
47
+ defaults?: BridgeDefaults;
48
+ /**
49
+ * De-duplicates in-flight HTTP requests.
50
+ *
51
+ * - `true` (default): single-flight for safe methods (`GET/HEAD/OPTIONS`)
52
+ * - `false`: disabled (each call triggers a new request)
53
+ */
54
+ singleFlight?: boolean;
55
+ /** Enables debug logging via the debug interceptor and console logger. */
56
+ debug?: boolean;
57
+ /** Extra `HttpInterceptorFn` applied after bridge interceptors. */
58
+ extraInterceptors?: HttpInterceptorFn[];
59
+ }