@obsidiane/meridiane 2.0.13 → 3.0.1
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 +33 -66
- package/cli.js +1 -10
- package/package.json +1 -1
- package/src/application/bridge-workflow.js +23 -66
- package/src/application/generate-bridge.js +32 -36
- package/src/application/run-build.js +8 -30
- package/src/application/run-dev.js +8 -63
- package/src/application/run-generate.js +8 -47
- package/src/domain/bridge-options.js +10 -63
- package/src/generators/bridge/write-bridge-files.js +11 -29
- package/src/generators/models/interface-schema.js +3 -20
- package/src/generators/models/model-selection.js +67 -106
- package/src/generators/models/oas-contract.js +1 -108
- package/src/generators/models/oas-schema-name.js +1 -19
- package/src/generators/models/openapi-to-models.js +51 -42
- package/src/generators/models/schema-utils.js +0 -34
- package/src/generators/models/type-resolver.js +2 -20
- package/src/generators/models/utils.js +0 -11
- package/src/generators/models/write-model-files.js +5 -15
- package/src/infrastructure/dev-project.js +0 -33
- package/src/infrastructure/files.js +15 -0
- package/src/infrastructure/local-package-install.js +2 -2
- package/src/infrastructure/logger.js +0 -2
- package/templates/angular-bridge/README.md +29 -281
- package/templates/angular-bridge/package.json +2 -2
- package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +10 -19
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-hub.ts +449 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.ts +28 -0
- package/templates/angular-bridge/src/lib/bridge/sse/resource-topic.ts +83 -0
- package/templates/angular-bridge/src/lib/bridge-options.ts +21 -17
- package/templates/angular-bridge/src/lib/bridge.types.ts +22 -33
- package/templates/angular-bridge/src/lib/facades/bridge.facade.ts +58 -134
- package/templates/angular-bridge/src/lib/facades/facade.factory.ts +25 -25
- package/templates/angular-bridge/src/lib/facades/facade.interface.ts +4 -9
- package/templates/angular-bridge/src/lib/facades/resource.facade.ts +38 -61
- package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +8 -12
- package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +19 -11
- package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +5 -17
- package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +28 -24
- package/templates/angular-bridge/src/lib/ports/realtime.port.ts +34 -53
- package/templates/angular-bridge/src/lib/ports/resource-repository.port.ts +31 -21
- package/templates/angular-bridge/src/lib/provide-bridge.ts +14 -38
- package/templates/angular-bridge/src/lib/tokens.ts +4 -15
- package/templates/angular-bridge/src/lib/utils/request-url.ts +4 -6
- package/templates/angular-bridge/src/lib/utils/url.ts +32 -9
- package/templates/angular-bridge/src/public-api.ts +0 -1
- package/templates/angular-bridge/tsconfig.lib.json +0 -3
- package/src/README.md +0 -9
- package/src/infrastructure/json.js +0 -71
- package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/eventsource-wrapper.ts +0 -70
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +0 -255
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -48
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +0 -39
- package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +0 -358
- package/templates/angular-bridge/src/lib/bridge/sse/ref-count-topic.registry.ts +0 -36
- package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +0 -76
- package/templates/angular-bridge/src/lib/utils/iri.ts +0 -6
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import {inject, Injectable, OnDestroy, PLATFORM_ID, signal} from '@angular/core';
|
|
2
|
+
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
|
|
3
|
+
import {HttpClient} from '@angular/common/http';
|
|
4
|
+
import {BehaviorSubject, EMPTY, firstValueFrom, Observable, Subject} from 'rxjs';
|
|
5
|
+
import {distinctUntilChanged, filter, map} from 'rxjs/operators';
|
|
6
|
+
import {API_BASE_URL, BRIDGE_HTTP_WITH_CREDENTIALS, BRIDGE_LOGGER, BRIDGE_MERCURE} from '../../tokens';
|
|
7
|
+
import {RealtimeDiagnostics, RealtimeStatus} from '../../bridge.types';
|
|
8
|
+
import {
|
|
9
|
+
MercureSubscription,
|
|
10
|
+
MercureUpdate,
|
|
11
|
+
RealtimePort,
|
|
12
|
+
RealtimeSubscribeOptions,
|
|
13
|
+
} from '../../ports/realtime.port';
|
|
14
|
+
import {resolveUrl, toAbsoluteUrl} from '../../utils/url';
|
|
15
|
+
import {buildSubscribeUrl, subscriptionKey} from './mercure-url';
|
|
16
|
+
|
|
17
|
+
const RETRY_MIN_DELAY_MS = 1_000;
|
|
18
|
+
const RETRY_MAX_DELAY_MS = 30_000;
|
|
19
|
+
/** Renewal margin before the authorization cookie expires. */
|
|
20
|
+
const RENEW_BEFORE_EXPIRY_MS = 5_000;
|
|
21
|
+
/** Maximum number of `match*` parameters the reference hub accepts in one subscription request. */
|
|
22
|
+
const MAX_MATCHERS = 100;
|
|
23
|
+
const DEFAULT_MAX_URL_LENGTH = 4_000;
|
|
24
|
+
const SHARED_GROUP = 'shared';
|
|
25
|
+
|
|
26
|
+
/** One `EventSource` and the subscriptions it carries. */
|
|
27
|
+
type HubConnection = {
|
|
28
|
+
/** Subscriptions only share connections of the same group. */
|
|
29
|
+
group: string;
|
|
30
|
+
/** `shared`, `dedicated` or the connection name, as exposed in the diagnostics. */
|
|
31
|
+
label: string;
|
|
32
|
+
subscriptions: Map<string, MercureSubscription>;
|
|
33
|
+
source?: EventSource;
|
|
34
|
+
/** Subscribe URL of the open source (without `last_event_id`). */
|
|
35
|
+
url?: string;
|
|
36
|
+
status: RealtimeStatus;
|
|
37
|
+
lastEventId?: string;
|
|
38
|
+
failures: number;
|
|
39
|
+
retryTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ActiveSubscription = {
|
|
43
|
+
subscription: MercureSubscription;
|
|
44
|
+
consumers: number;
|
|
45
|
+
connection: HubConnection;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type GroupedUpdate = {
|
|
49
|
+
group: string;
|
|
50
|
+
update: MercureUpdate;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mercure connections of the whole application.
|
|
55
|
+
*
|
|
56
|
+
* Subscriptions are spread over as few connections as possible within their group (`shared` by default, a name,
|
|
57
|
+
* or one connection per `dedicated` subscription): a connection takes subscriptions until its subscribe URL would
|
|
58
|
+
* exceed `maxUrlLength` or 100 matchers. Mercure cannot change the subscriptions of an open stream, so a connection
|
|
59
|
+
* is reopened when its own subscriptions change, with `last_event_id` set to its last received update to request
|
|
60
|
+
* replay from the hub's retained history.
|
|
61
|
+
*/
|
|
62
|
+
@Injectable({providedIn: 'root'})
|
|
63
|
+
export class MercureHub implements RealtimePort, OnDestroy {
|
|
64
|
+
private readonly options = inject(BRIDGE_MERCURE, {optional: true}) ?? undefined;
|
|
65
|
+
private readonly http = inject(HttpClient);
|
|
66
|
+
private readonly apiBaseUrl = inject(API_BASE_URL);
|
|
67
|
+
private readonly httpWithCredentials = inject(BRIDGE_HTTP_WITH_CREDENTIALS);
|
|
68
|
+
private readonly logger = inject(BRIDGE_LOGGER, {optional: true}) ?? undefined;
|
|
69
|
+
private readonly hubUrl = this.options
|
|
70
|
+
? toAbsoluteUrl(this.options.hubUrl, toAbsoluteUrl(this.apiBaseUrl, inject(DOCUMENT).baseURI))
|
|
71
|
+
: undefined;
|
|
72
|
+
private readonly withCredentials = this.options?.withCredentials ?? true;
|
|
73
|
+
private readonly maxUrlLength = this.options?.maxUrlLength ?? DEFAULT_MAX_URL_LENGTH;
|
|
74
|
+
private readonly enabled = !!this.hubUrl && isPlatformBrowser(inject(PLATFORM_ID));
|
|
75
|
+
|
|
76
|
+
private readonly subscriptions = new Map<string, ActiveSubscription>();
|
|
77
|
+
private readonly connections: HubConnection[] = [];
|
|
78
|
+
private readonly updates = new Subject<GroupedUpdate>();
|
|
79
|
+
private readonly diagnostics = new BehaviorSubject<RealtimeDiagnostics>(this.snapshot());
|
|
80
|
+
private readonly statusSignal = signal<RealtimeStatus>('closed');
|
|
81
|
+
|
|
82
|
+
readonly status = this.statusSignal.asReadonly();
|
|
83
|
+
|
|
84
|
+
private authorization?: Promise<void>;
|
|
85
|
+
private authorizing = false;
|
|
86
|
+
private renewTimer?: ReturnType<typeof setTimeout>;
|
|
87
|
+
private destroyed = false;
|
|
88
|
+
private suspended = false;
|
|
89
|
+
private dedicatedSequence = 0;
|
|
90
|
+
private syncTimer?: ReturnType<typeof setTimeout>;
|
|
91
|
+
|
|
92
|
+
private readonly onPageHide = (event: PageTransitionEvent) => {
|
|
93
|
+
// Free the connections, and let the page enter the back/forward cache.
|
|
94
|
+
if (!event.persisted) return;
|
|
95
|
+
this.suspended = true;
|
|
96
|
+
clearTimeout(this.syncTimer);
|
|
97
|
+
for (const connection of this.connections) {
|
|
98
|
+
this.closeSource(connection);
|
|
99
|
+
connection.status = 'closed';
|
|
100
|
+
}
|
|
101
|
+
this.publishDiagnostics();
|
|
102
|
+
};
|
|
103
|
+
private readonly onPageShow = (event: PageTransitionEvent) => {
|
|
104
|
+
if (!event.persisted) return;
|
|
105
|
+
this.suspended = false;
|
|
106
|
+
this.scheduleSync();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
constructor() {
|
|
110
|
+
if (this.enabled) {
|
|
111
|
+
window.addEventListener('pagehide', this.onPageHide);
|
|
112
|
+
window.addEventListener('pageshow', this.onPageShow);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
subscribe$(subscription: MercureSubscription, options?: RealtimeSubscribeOptions): Observable<MercureUpdate> {
|
|
117
|
+
if (!this.enabled || this.destroyed) {
|
|
118
|
+
if (!this.hubUrl) this.logger?.debug?.('[Mercure] hubUrl not configured: realtime disabled');
|
|
119
|
+
return EMPTY;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Observable<MercureUpdate>((subscriber) => {
|
|
123
|
+
const {group, label} = this.groupOf(options?.connection);
|
|
124
|
+
const updates = this.updates
|
|
125
|
+
.pipe(
|
|
126
|
+
filter((grouped) => grouped.group === group),
|
|
127
|
+
map((grouped) => grouped.update)
|
|
128
|
+
)
|
|
129
|
+
.subscribe(subscriber);
|
|
130
|
+
const key = this.retain(group, label, subscription);
|
|
131
|
+
return () => {
|
|
132
|
+
updates.unsubscribe();
|
|
133
|
+
this.release(key);
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
reconnect(): void {
|
|
139
|
+
if (!this.enabled || this.destroyed) return;
|
|
140
|
+
void this.authorize('restart').then(() => {
|
|
141
|
+
if (this.destroyed || this.suspended) return;
|
|
142
|
+
for (const connection of this.connections) {
|
|
143
|
+
if (connection.subscriptions.size > 0) this.open(connection, this.subscribeUrl(connection.subscriptions));
|
|
144
|
+
}
|
|
145
|
+
this.publishDiagnostics();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
status$(): Observable<RealtimeStatus> {
|
|
150
|
+
return this.diagnostics.pipe(map((diagnostics) => diagnostics.status), distinctUntilChanged());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
diagnostics$(): Observable<RealtimeDiagnostics> {
|
|
154
|
+
return this.diagnostics.asObservable();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ngOnDestroy(): void {
|
|
158
|
+
this.destroyed = true;
|
|
159
|
+
clearTimeout(this.syncTimer);
|
|
160
|
+
clearTimeout(this.renewTimer);
|
|
161
|
+
for (const connection of this.connections) this.closeSource(connection);
|
|
162
|
+
this.connections.length = 0;
|
|
163
|
+
this.subscriptions.clear();
|
|
164
|
+
if (this.enabled) {
|
|
165
|
+
window.removeEventListener('pagehide', this.onPageHide);
|
|
166
|
+
window.removeEventListener('pageshow', this.onPageShow);
|
|
167
|
+
}
|
|
168
|
+
this.updates.complete();
|
|
169
|
+
this.diagnostics.complete();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private groupOf(connection: RealtimeSubscribeOptions['connection'] = SHARED_GROUP): {group: string; label: string} {
|
|
173
|
+
if (connection === 'dedicated') return {group: `dedicated:${++this.dedicatedSequence}`, label: connection};
|
|
174
|
+
if (connection === SHARED_GROUP) return {group: SHARED_GROUP, label: connection};
|
|
175
|
+
return {group: `named:${connection}`, label: connection};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Returns the key of the subscription, shared by its consumers within the group. */
|
|
179
|
+
private retain(group: string, label: string, subscription: MercureSubscription): string {
|
|
180
|
+
const key = `${group} ${subscriptionKey(subscription)}`;
|
|
181
|
+
const active = this.subscriptions.get(key);
|
|
182
|
+
if (active) {
|
|
183
|
+
active.consumers++;
|
|
184
|
+
this.publishDiagnostics();
|
|
185
|
+
return key;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const connection = this.connectionFor(group, label, key, subscription);
|
|
189
|
+
connection.subscriptions.set(key, subscription);
|
|
190
|
+
this.subscriptions.set(key, {subscription, consumers: 1, connection});
|
|
191
|
+
this.scheduleSync();
|
|
192
|
+
return key;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private release(key: string): void {
|
|
196
|
+
const active = this.subscriptions.get(key);
|
|
197
|
+
if (!active) return;
|
|
198
|
+
|
|
199
|
+
if (--active.consumers > 0) {
|
|
200
|
+
this.publishDiagnostics();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.subscriptions.delete(key);
|
|
205
|
+
active.connection.subscriptions.delete(key);
|
|
206
|
+
this.scheduleSync();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** First connection of the group that stays within the limits with this subscription, or a new connection. */
|
|
210
|
+
private connectionFor(group: string, label: string, key: string, subscription: MercureSubscription): HubConnection {
|
|
211
|
+
const fitting = this.connections.find((connection) => {
|
|
212
|
+
if (connection.group !== group || connection.subscriptions.size >= MAX_MATCHERS) return false;
|
|
213
|
+
const subscriptions = new Map(connection.subscriptions).set(key, subscription);
|
|
214
|
+
return this.subscribeUrl(subscriptions).length <= this.maxUrlLength;
|
|
215
|
+
});
|
|
216
|
+
if (fitting) return fitting;
|
|
217
|
+
|
|
218
|
+
const connection: HubConnection = {group, label, subscriptions: new Map(), status: 'closed', failures: 0};
|
|
219
|
+
this.connections.push(connection);
|
|
220
|
+
return connection;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Coalesces the subscription changes of the current task (e.g. a route change) into one reconciliation. */
|
|
224
|
+
private scheduleSync(): void {
|
|
225
|
+
if (this.destroyed || this.suspended) return;
|
|
226
|
+
clearTimeout(this.syncTimer);
|
|
227
|
+
this.syncTimer = setTimeout(() => void this.sync());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async sync(): Promise<void> {
|
|
231
|
+
await this.authorize();
|
|
232
|
+
if (this.destroyed || this.suspended) return;
|
|
233
|
+
|
|
234
|
+
for (const connection of [...this.connections]) {
|
|
235
|
+
if (connection.subscriptions.size === 0) {
|
|
236
|
+
this.remove(connection);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// A refused connection is reopened by its retry, with its subscriptions at that time.
|
|
240
|
+
if (connection.retryTimer) continue;
|
|
241
|
+
|
|
242
|
+
const url = this.subscribeUrl(connection.subscriptions);
|
|
243
|
+
if (connection.source && url === connection.url) continue;
|
|
244
|
+
this.open(connection, url);
|
|
245
|
+
}
|
|
246
|
+
this.publishDiagnostics();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private open(connection: HubConnection, url: string): void {
|
|
250
|
+
this.closeSource(connection);
|
|
251
|
+
if (url.length > this.maxUrlLength) {
|
|
252
|
+
this.logger?.warn?.('[Mercure] a single subscription exceeds maxUrlLength', {url});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const resumeUrl = this.subscribeUrl(connection.subscriptions, connection.lastEventId);
|
|
256
|
+
this.logger?.debug?.('[Mercure] connect', {url: resumeUrl});
|
|
257
|
+
|
|
258
|
+
const source = new EventSource(resumeUrl, {withCredentials: this.withCredentials});
|
|
259
|
+
connection.source = source;
|
|
260
|
+
connection.url = url;
|
|
261
|
+
connection.status = 'connecting';
|
|
262
|
+
|
|
263
|
+
source.onopen = () => {
|
|
264
|
+
if (connection.source !== source) return;
|
|
265
|
+
connection.failures = 0;
|
|
266
|
+
connection.status = 'connected';
|
|
267
|
+
this.publishDiagnostics();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
source.onmessage = (event) => {
|
|
271
|
+
if (connection.source !== source) return;
|
|
272
|
+
if (event.lastEventId) {
|
|
273
|
+
connection.lastEventId = event.lastEventId;
|
|
274
|
+
this.publishDiagnostics();
|
|
275
|
+
}
|
|
276
|
+
const data = parseJson(event.data);
|
|
277
|
+
if (data === undefined) {
|
|
278
|
+
this.logger?.debug?.('[Mercure] non-JSON update ignored', {id: event.lastEventId});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.updates.next({group: connection.group, update: {id: event.lastEventId || undefined, data}});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
source.onerror = () => {
|
|
285
|
+
if (connection.source !== source) return;
|
|
286
|
+
if (source.readyState !== EventSource.CLOSED) {
|
|
287
|
+
// Network interruption, or the hub ending the stream before the token expires:
|
|
288
|
+
// the browser reconnects by itself, with `Last-Event-ID` and the current cookie.
|
|
289
|
+
connection.status = 'connecting';
|
|
290
|
+
this.publishDiagnostics();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The hub refused the request (401 / 403 / 429 / 400...): EventSource gives up.
|
|
295
|
+
// Renew the authorization (e.g. expired token), then reopen with backoff.
|
|
296
|
+
connection.failures++;
|
|
297
|
+
this.closeSource(connection);
|
|
298
|
+
connection.status = 'connecting';
|
|
299
|
+
const delay = Math.min(RETRY_MIN_DELAY_MS * 2 ** (connection.failures - 1), RETRY_MAX_DELAY_MS);
|
|
300
|
+
this.logger?.warn?.('[Mercure] subscription refused by the hub, retrying', {delay, failures: connection.failures});
|
|
301
|
+
connection.retryTimer = setTimeout(() => void this.retry(connection), delay);
|
|
302
|
+
this.publishDiagnostics();
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The hub ends each stream before its token expires, and the browser reconnects by itself with `Last-Event-ID`.
|
|
308
|
+
* When the authorization endpoint returns `expiresIn`, the cookie is renewed 5 seconds before it expires, so that
|
|
309
|
+
* reconnections keep carrying a valid token instead of being refused.
|
|
310
|
+
*/
|
|
311
|
+
private scheduleRenewal(expiresIn: number | undefined): void {
|
|
312
|
+
clearTimeout(this.renewTimer);
|
|
313
|
+
if (expiresIn === undefined || this.destroyed) return;
|
|
314
|
+
|
|
315
|
+
this.renewTimer = setTimeout(() => {
|
|
316
|
+
if (this.connections.some((connection) => connection.subscriptions.size > 0)) {
|
|
317
|
+
void this.authorize('renew');
|
|
318
|
+
} else {
|
|
319
|
+
// Nothing open: the next connection asks for a new cookie.
|
|
320
|
+
this.authorization = undefined;
|
|
321
|
+
}
|
|
322
|
+
}, Math.max(expiresIn * 1_000 - RENEW_BEFORE_EXPIRY_MS, RETRY_MIN_DELAY_MS));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async retry(connection: HubConnection): Promise<void> {
|
|
326
|
+
await this.authorize('renew');
|
|
327
|
+
connection.retryTimer = undefined;
|
|
328
|
+
if (this.destroyed || this.suspended || !this.connections.includes(connection)) return;
|
|
329
|
+
|
|
330
|
+
if (connection.subscriptions.size === 0) this.remove(connection);
|
|
331
|
+
else this.open(connection, this.subscribeUrl(connection.subscriptions));
|
|
332
|
+
this.publishDiagnostics();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Obtains the authorization cookie.
|
|
337
|
+
* - `once`: the first time only.
|
|
338
|
+
* - `renew` (refused connection): again, sharing the request in flight.
|
|
339
|
+
* - `restart` (e.g. after a login or logout): again, once the request in flight is over, so the cookie set last
|
|
340
|
+
* belongs to the current identity.
|
|
341
|
+
*
|
|
342
|
+
* Resolves once the latest request is over: a request started meanwhile (e.g. by `reconnect()`) supersedes this one.
|
|
343
|
+
*/
|
|
344
|
+
private async authorize(mode: 'once' | 'renew' | 'restart' = 'once'): Promise<void> {
|
|
345
|
+
let authorization = this.requestAuthorization(mode);
|
|
346
|
+
while (authorization) {
|
|
347
|
+
await authorization;
|
|
348
|
+
if (authorization === this.authorization) return;
|
|
349
|
+
authorization = this.authorization;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private requestAuthorization(mode: 'once' | 'renew' | 'restart'): Promise<void> | undefined {
|
|
354
|
+
const authorize = this.options?.authorize;
|
|
355
|
+
if (!authorize) return undefined;
|
|
356
|
+
if (this.authorization && (mode === 'once' || (mode === 'renew' && this.authorizing))) return this.authorization;
|
|
357
|
+
|
|
358
|
+
const request = () => typeof authorize === 'function'
|
|
359
|
+
? authorize()
|
|
360
|
+
: firstValueFrom(
|
|
361
|
+
this.http.get(resolveUrl(this.apiBaseUrl, authorize), {withCredentials: this.httpWithCredentials}),
|
|
362
|
+
{defaultValue: undefined}
|
|
363
|
+
);
|
|
364
|
+
const authorization: Promise<void> = (this.authorizing ? this.authorization! : Promise.resolve())
|
|
365
|
+
.then(request)
|
|
366
|
+
.then(
|
|
367
|
+
(response) => {
|
|
368
|
+
if (this.authorization === authorization) this.scheduleRenewal(expiresInOf(response));
|
|
369
|
+
},
|
|
370
|
+
(error: unknown) => this.logger?.warn?.('[Mercure] authorization failed', error)
|
|
371
|
+
)
|
|
372
|
+
.finally(() => {
|
|
373
|
+
if (this.authorization === authorization) this.authorizing = false;
|
|
374
|
+
});
|
|
375
|
+
this.authorization = authorization;
|
|
376
|
+
this.authorizing = true;
|
|
377
|
+
return authorization;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private remove(connection: HubConnection): void {
|
|
381
|
+
this.closeSource(connection);
|
|
382
|
+
this.connections.splice(this.connections.indexOf(connection), 1);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private closeSource(connection: HubConnection): void {
|
|
386
|
+
clearTimeout(connection.retryTimer);
|
|
387
|
+
connection.retryTimer = undefined;
|
|
388
|
+
if (!connection.source) return;
|
|
389
|
+
|
|
390
|
+
connection.source.onopen = null;
|
|
391
|
+
connection.source.onmessage = null;
|
|
392
|
+
connection.source.onerror = null;
|
|
393
|
+
connection.source.close();
|
|
394
|
+
connection.source = undefined;
|
|
395
|
+
connection.url = undefined;
|
|
396
|
+
this.logger?.debug?.('[Mercure] closed');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private subscribeUrl(subscriptions: Map<string, MercureSubscription>, lastEventId?: string): string {
|
|
400
|
+
const sorted = [...subscriptions.entries()]
|
|
401
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
402
|
+
.map(([, subscription]) => subscription);
|
|
403
|
+
return buildSubscribeUrl(this.hubUrl!, sorted, lastEventId);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private publishDiagnostics(): void {
|
|
407
|
+
const diagnostics = this.snapshot();
|
|
408
|
+
this.statusSignal.set(diagnostics.status);
|
|
409
|
+
this.diagnostics.next(diagnostics);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private snapshot(): RealtimeDiagnostics {
|
|
413
|
+
return {
|
|
414
|
+
status: aggregateStatus(this.connections.map((connection) => connection.status)),
|
|
415
|
+
connections: this.connections.map((connection) => ({
|
|
416
|
+
connection: connection.label,
|
|
417
|
+
status: connection.status,
|
|
418
|
+
url: connection.url,
|
|
419
|
+
subscriptions: [...connection.subscriptions.entries()].map(([key, subscription]) => ({
|
|
420
|
+
match: subscription.match,
|
|
421
|
+
type: subscription.type,
|
|
422
|
+
consumers: this.subscriptions.get(key)?.consumers ?? 0,
|
|
423
|
+
})),
|
|
424
|
+
lastEventId: connection.lastEventId,
|
|
425
|
+
failures: connection.failures,
|
|
426
|
+
})),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function aggregateStatus(statuses: RealtimeStatus[]): RealtimeStatus {
|
|
432
|
+
if (statuses.every((status) => status === 'closed')) return 'closed';
|
|
433
|
+
if (statuses.every((status) => status === 'connected')) return 'connected';
|
|
434
|
+
return 'connecting';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** `expiresIn` (seconds) of the authorization response, when the endpoint returns it. */
|
|
438
|
+
function expiresInOf(response: unknown): number | undefined {
|
|
439
|
+
const expiresIn = (response as {expiresIn?: unknown} | null | undefined)?.expiresIn;
|
|
440
|
+
return typeof expiresIn === 'number' && expiresIn > 0 ? expiresIn : undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseJson(raw: string): unknown {
|
|
444
|
+
try {
|
|
445
|
+
return JSON.parse(raw);
|
|
446
|
+
} catch {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {MercureSubscription} from '../../ports/realtime.port';
|
|
2
|
+
|
|
3
|
+
const MATCH_PARAMETER: Record<MercureSubscription['type'], string> = {
|
|
4
|
+
exact: 'match',
|
|
5
|
+
urlpattern: 'match_urlpattern',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds a Mercure 1.0 subscribe URL: one `match` / `match_urlpattern` parameter per subscription,
|
|
10
|
+
* plus `last_event_id` to resume from the last received update (browsers cannot set the
|
|
11
|
+
* `Last-Event-ID` header on the first request).
|
|
12
|
+
*/
|
|
13
|
+
export function buildSubscribeUrl(
|
|
14
|
+
hubUrl: string,
|
|
15
|
+
subscriptions: Iterable<MercureSubscription>,
|
|
16
|
+
lastEventId?: string
|
|
17
|
+
): string {
|
|
18
|
+
const url = new URL(hubUrl);
|
|
19
|
+
for (const subscription of subscriptions) {
|
|
20
|
+
url.searchParams.append(MATCH_PARAMETER[subscription.type], subscription.match);
|
|
21
|
+
}
|
|
22
|
+
if (lastEventId) url.searchParams.set('last_event_id', lastEventId);
|
|
23
|
+
return url.toString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function subscriptionKey(subscription: MercureSubscription): string {
|
|
27
|
+
return `${subscription.type} ${subscription.match}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {Item} from '../../ports/resource-repository.port';
|
|
2
|
+
import type {MercureSubscription} from '../../ports/realtime.port';
|
|
3
|
+
import {resolveUrl} from '../../utils/url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Payload API Platform publishes on deletion: `{"@id"}`, plus `@type` with `mercure.include_type`.
|
|
7
|
+
* Never a `@context`, unlike a serialized resource: a generated model therefore never matches this type.
|
|
8
|
+
*/
|
|
9
|
+
export interface ResourceDeletion {
|
|
10
|
+
'@id': string;
|
|
11
|
+
'@type'?: string | string[];
|
|
12
|
+
'@context'?: never;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UrlPatternLike {
|
|
16
|
+
test(input: string): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type UrlPatternConstructor = new (input: string) => UrlPatternLike;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recognizes the deletion payload API Platform builds by hand. Creations and updates are serialized
|
|
23
|
+
* resources, which always carry `@context` in JSON-LD.
|
|
24
|
+
*/
|
|
25
|
+
export function isResourceDeletion(payload: object): payload is ResourceDeletion {
|
|
26
|
+
return typeof (payload as ResourceDeletion)['@id'] === 'string'
|
|
27
|
+
&& Object.keys(payload).every((key) => key === '@id' || key === '@type');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A resource URL Pattern or an exact item IRI, as published by API Platform.
|
|
32
|
+
* The same selector is sent to the hub and used to route updates by `@id`.
|
|
33
|
+
*/
|
|
34
|
+
export class ResourceTopic implements MercureSubscription {
|
|
35
|
+
readonly match: string;
|
|
36
|
+
readonly type: MercureSubscription['type'];
|
|
37
|
+
private readonly apiBaseUrl: string;
|
|
38
|
+
private pattern?: UrlPatternLike;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
match: string,
|
|
42
|
+
apiBaseUrl: string,
|
|
43
|
+
type: MercureSubscription['type'] = 'urlpattern',
|
|
44
|
+
) {
|
|
45
|
+
this.match = match;
|
|
46
|
+
this.apiBaseUrl = apiBaseUrl;
|
|
47
|
+
this.type = type;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
forIri(iri: string): ResourceTopic {
|
|
51
|
+
if (!iri.trim()) throw new Error('ResourceFacade.watch$(): missing resource IRI');
|
|
52
|
+
return new ResourceTopic(resolveUrl(this.apiBaseUrl, iri), this.apiBaseUrl, 'exact');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Returns the original payload, or `undefined` when it belongs to another resource. */
|
|
56
|
+
toResource<T extends Item>(payload: unknown): T | ResourceDeletion | undefined {
|
|
57
|
+
if (!isRecord(payload)) return undefined;
|
|
58
|
+
|
|
59
|
+
const iri = payload['@id'];
|
|
60
|
+
if (typeof iri !== 'string' || !this.matches(iri)) return undefined;
|
|
61
|
+
|
|
62
|
+
return payload as T | ResourceDeletion;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private matches(iri: string): boolean {
|
|
66
|
+
const url = resolveUrl(this.apiBaseUrl, iri);
|
|
67
|
+
if (this.type === 'exact') return url === this.match;
|
|
68
|
+
this.pattern ??= compileUrlPattern(this.match);
|
|
69
|
+
return this.pattern.test(url);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compileUrlPattern(pattern: string): UrlPatternLike {
|
|
74
|
+
const URLPatternImpl = (globalThis as {URLPattern?: UrlPatternConstructor}).URLPattern;
|
|
75
|
+
if (!URLPatternImpl) {
|
|
76
|
+
throw new Error('URLPattern is not available in this environment: load a polyfill such as `urlpattern-polyfill`.');
|
|
77
|
+
}
|
|
78
|
+
return new URLPatternImpl(pattern);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
83
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {HttpInterceptorFn} from '@angular/common/http';
|
|
2
|
-
import {BridgeDefaults
|
|
2
|
+
import {BridgeDefaults} from './bridge.types';
|
|
3
3
|
|
|
4
4
|
export type BridgeAuth =
|
|
5
5
|
| string
|
|
@@ -8,29 +8,33 @@ export type BridgeAuth =
|
|
|
8
8
|
| HttpInterceptorFn;
|
|
9
9
|
|
|
10
10
|
export interface BridgeMercureOptions {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
topicMode?: MercureTopicMode;
|
|
11
|
+
/** Mercure hub URL (e.g. `https://api.example.com/.well-known/mercure`), resolved against `baseUrl` when relative. */
|
|
12
|
+
hubUrl: string;
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Sends cookies with the subscription request. Default: `true`.
|
|
15
|
+
*
|
|
16
|
+
* Browsers authenticate to a Mercure 1.0 hub with the HttpOnly `__Secure-mercure_access_token`
|
|
17
|
+
* cookie set by the backend (`EventSource` cannot send an `Authorization` header).
|
|
18
|
+
*/
|
|
19
|
+
withCredentials?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Obtains the Mercure authorization cookie: an API endpoint called with `GET` (e.g. `/api/mercure/authorization`),
|
|
22
|
+
* or a function. Called before the first connection and after each connection refused by the hub
|
|
23
|
+
* (e.g. expired token), before reconnecting.
|
|
24
|
+
*
|
|
25
|
+
* When the endpoint returns (or the function resolves) `{expiresIn: <seconds>}`, the cookie is renewed 5 seconds
|
|
26
|
+
* before it expires: when the hub ends a connection, the browser reconnects with a valid one.
|
|
18
27
|
*/
|
|
19
|
-
|
|
28
|
+
authorize?: string | (() => Promise<unknown>);
|
|
20
29
|
/**
|
|
21
|
-
* Maximum
|
|
22
|
-
*
|
|
30
|
+
* Maximum length of a subscribe URL (without `last_event_id`). Beyond it, or beyond 100 subscriptions,
|
|
31
|
+
* the bridge opens another connection. Default: `4000`.
|
|
23
32
|
*/
|
|
24
33
|
maxUrlLength?: number;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
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
|
-
*/
|
|
37
|
+
/** Enables cookies / credentials on HTTP requests by default. Default: `false`. */
|
|
34
38
|
withCredentials?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -41,7 +45,7 @@ export interface BridgeOptions {
|
|
|
41
45
|
auth?: BridgeAuth;
|
|
42
46
|
/** Default HTTP transport options. */
|
|
43
47
|
http?: BridgeHttpOptions;
|
|
44
|
-
/**
|
|
48
|
+
/** Enables Mercure realtime. */
|
|
45
49
|
mercure?: BridgeMercureOptions;
|
|
46
50
|
/** Default HTTP behaviour (headers, timeout, retries). */
|
|
47
51
|
defaults?: BridgeDefaults;
|