@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
@@ -5,13 +5,13 @@ import { createRequire } from 'node:module';
5
5
  import { runCommand } from './exec.js';
6
6
  import { writeJsonIfChanged } from './json.js';
7
7
 
8
- async function ensureDir(p) {
9
- await fs.mkdir(p, { recursive: true });
8
+ async function ensureDirectory(directory) {
9
+ await fs.mkdir(directory, { recursive: true });
10
10
  }
11
11
 
12
- async function exists(p) {
12
+ async function exists(filePath) {
13
13
  try {
14
- await fs.stat(p);
14
+ await fs.stat(filePath);
15
15
  return true;
16
16
  } catch {
17
17
  return false;
@@ -19,16 +19,15 @@ async function exists(p) {
19
19
  }
20
20
 
21
21
  function toolchainPackageJson({ angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion }) {
22
- const v = angularVersion;
23
22
  return {
24
23
  name: 'meridiane-standalone-workspace',
25
24
  private: true,
26
25
  description: 'Temporary workspace created by @obsidiane/meridiane (standalone build).',
27
26
  devDependencies: {
28
- '@angular/common': v,
29
- '@angular/compiler': v,
30
- '@angular/compiler-cli': v,
31
- '@angular/core': v,
27
+ '@angular/common': angularVersion,
28
+ '@angular/compiler': angularVersion,
29
+ '@angular/compiler-cli': angularVersion,
30
+ '@angular/core': angularVersion,
32
31
  'ng-packagr': ngPackagrVersion,
33
32
  rxjs: rxjsVersion,
34
33
  tslib: tslibVersion,
@@ -62,8 +61,8 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
62
61
  const distRoot = path.join(repoRoot, 'dist');
63
62
  const workspaceRoot = path.join(distRoot, '.meridiane-workspace');
64
63
 
65
- await ensureDir(distRoot);
66
- await ensureDir(workspaceRoot);
64
+ await ensureDirectory(distRoot);
65
+ await ensureDirectory(workspaceRoot);
67
66
 
68
67
  const detected = await detectToolchainVersions(repoRoot);
69
68
  const angularVersion = detected.angularVersion ?? '21.1.4';
@@ -80,7 +79,7 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
80
79
  await writeJsonIfChanged(path.join(workspaceRoot, 'tsconfig.json'), rootTsconfigJson());
81
80
 
82
81
  const npmCacheDir = path.join(workspaceRoot, '.npm-cache');
83
- await ensureDir(npmCacheDir);
82
+ await ensureDirectory(npmCacheDir);
84
83
 
85
84
  const requireFromWs = createRequire(path.join(workspaceRoot, 'package.json'));
86
85
 
@@ -88,26 +87,29 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
88
87
  }
89
88
 
90
89
  async function detectToolchainVersions(repoRoot) {
91
- const pkgPath = path.join(repoRoot, 'package.json');
90
+ const packageJsonPath = path.join(repoRoot, 'package.json');
92
91
  try {
93
- const raw = await fs.readFile(pkgPath, 'utf8');
94
- const parsed = JSON.parse(raw);
95
- const angularVersion = readDependencyVersion(parsed, '@angular/core');
96
- const ngPackagrVersion = readDependencyVersion(parsed, 'ng-packagr');
97
- const typescriptVersion = readDependencyVersion(parsed, 'typescript');
98
- const rxjsVersion = readDependencyVersion(parsed, 'rxjs');
99
- const tslibVersion = readDependencyVersion(parsed, 'tslib');
92
+ const content = await fs.readFile(packageJsonPath, 'utf8');
93
+ const packageJson = JSON.parse(content);
94
+ const angularVersion = readDependencyVersion(packageJson, '@angular/core');
95
+ const ngPackagrVersion = readDependencyVersion(packageJson, 'ng-packagr');
96
+ const typescriptVersion = readDependencyVersion(packageJson, 'typescript');
97
+ const rxjsVersion = readDependencyVersion(packageJson, 'rxjs');
98
+ const tslibVersion = readDependencyVersion(packageJson, 'tslib');
100
99
  return { angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion };
101
100
  } catch {
102
101
  return {};
103
102
  }
104
103
  }
105
104
 
106
- function readDependencyVersion(pkg, depName) {
107
- const deps = pkg?.dependencies || {};
108
- const devDeps = pkg?.devDependencies || {};
109
- const peerDeps = pkg?.peerDependencies || {};
110
- const value = deps[depName] ?? devDeps[depName] ?? peerDeps[depName];
105
+ function readDependencyVersion(packageJson, dependencyName) {
106
+ const dependencies = packageJson?.dependencies || {};
107
+ const devDependencies = packageJson?.devDependencies || {};
108
+ const peerDependencies = packageJson?.peerDependencies || {};
109
+ const value =
110
+ dependencies[dependencyName] ??
111
+ devDependencies[dependencyName] ??
112
+ peerDependencies[dependencyName];
111
113
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
112
114
  }
113
115
 
@@ -127,9 +129,9 @@ function deriveToolchainDefaultsFromAngular(angularVersion) {
127
129
 
128
130
  export async function ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log }) {
129
131
  // Prefer using existing (hoisted) deps during local dev; in CI this will trigger installation.
130
- const canResolve = (id) => {
132
+ const canResolve = (packageId) => {
131
133
  try {
132
- requireFromWs.resolve(id);
134
+ requireFromWs.resolve(packageId);
133
135
  return true;
134
136
  } catch {
135
137
  return false;
@@ -150,9 +152,9 @@ export async function ensureToolchainInstalled({ workspaceRoot, requireFromWs, n
150
152
  };
151
153
 
152
154
  // We avoid generating a lockfile in the backend repo by keeping everything under dist/.
153
- const npmCmd = 'npm';
155
+ const npmCommand = 'npm';
154
156
  const args = ['install', '--silent', '--no-progress'];
155
- const code = await runCommand(npmCmd, args, { cwd: workspaceRoot, env });
157
+ const code = await runCommand(npmCommand, args, { cwd: workspaceRoot, env });
156
158
 
157
159
  if (code !== 0) throw new Error(`Toolchain install failed (npm exit ${code})`);
158
160
  if (!(await exists(path.join(workspaceRoot, 'node_modules')))) {
@@ -60,27 +60,40 @@ export class AppModule {}
60
60
  `provideBridge({ ... })` accepte notamment :
61
61
  - `baseUrl` (requis) ;
62
62
  - `auth` (Bearer ou interceptor custom) ;
63
+ - `http` (`withCredentials` HTTP par défaut) ;
63
64
  - `mercure` (hubUrl + `topicMode` + stratégie de connexions SSE `connectionMode`/`maxUrlLength`) ;
64
65
  - `defaults` (headers/timeout/retries) ;
65
66
  - `singleFlight` (déduplication “in-flight” des requêtes HTTP identiques `GET/HEAD/OPTIONS`) ;
66
67
  - `debug` (logs runtime) ;
67
68
  - `extraInterceptors` (interceptors Angular additionnels).
68
69
 
70
+ Les comportements HTTP internes du bridge s'appliquent uniquement aux requêtes
71
+ appartenant à l'origine et au chemin de `baseUrl`. Les interceptors personnalisés
72
+ restent responsables de leur propre périmètre.
73
+
69
74
  ### Cookies / `withCredentials`
70
75
 
71
76
  Le bridge peut envoyer des cookies (sessions) côté HTTP et SSE.
72
77
 
73
- Le comportement `withCredentials` par défaut est déduit de `mercure.init` :
74
- - `credentials: 'include'` (défaut) → cookies envoyés
75
- - `credentials: 'omit'` → cookies non envoyés
78
+ Vous pouvez configurer explicitement le HTTP via `http.withCredentials` :
79
+ - `true` → cookies envoyés sur les requêtes HTTP
80
+ - `false` → cookies désactivés côté HTTP
81
+
82
+ `mercure.init.credentials` pilote uniquement Mercure/SSE :
83
+ - `credentials: 'include'` (défaut) → cookies envoyés sur EventSource
84
+ - `credentials: 'omit'` → cookies désactivés côté SSE
76
85
 
77
- Vous pouvez aussi surcharger au cas par cas via `opts.withCredentials` sur les appels HTTP.
86
+ Compat legacy :
87
+ - si `http.withCredentials` est absent, le bridge conserve le comportement historique et le déduit de `mercure.init.credentials`
88
+
89
+ Vous pouvez aussi surcharger appel par appel via `opts.withCredentials` sur les appels HTTP.
78
90
 
79
91
  ```ts
80
92
  import {provideBridge} from '__PACKAGE_NAME__';
81
93
 
82
94
  provideBridge({
83
95
  baseUrl: 'https://api.example.com',
96
+ http: {withCredentials: true},
84
97
  mercure: {
85
98
  hubUrl: 'https://api.example.com/.well-known/mercure',
86
99
  init: {credentials: 'omit'},
@@ -96,6 +109,11 @@ provideBridge({
96
109
  - `{ type: 'bearer', getToken }` (sync ou async),
97
110
  - ou un `HttpInterceptorFn` custom.
98
111
 
112
+ Le Bearer intégré est ajouté uniquement aux requêtes dont l’origine et le chemin
113
+ appartiennent au `baseUrl` configuré. Une requête vers un autre service conserve
114
+ donc ses en-têtes d’origine. Un interceptor custom garde le comportement Angular
115
+ standard et reste responsable de son propre périmètre.
116
+
99
117
  ```ts
100
118
  import {provideBridge} from '__PACKAGE_NAME__';
101
119
 
@@ -0,0 +1,82 @@
1
+ import {HttpClient} from '@angular/common/http';
2
+ import {Observable} from 'rxjs';
3
+ import {toHttpParams} from './query-builder';
4
+ import {buildHttpRequestOptions, resolveHttpCallOptions} from './http-request.options';
5
+ import {
6
+ Collection,
7
+ HttpCallOptions,
8
+ HttpRequestConfig,
9
+ Iri,
10
+ IriRequired,
11
+ Item,
12
+ AnyQuery,
13
+ ResourceRepository,
14
+ } from '../../ports/resource-repository.port';
15
+ import {resolveUrl} from '../../utils/url';
16
+
17
+ export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
18
+ constructor(
19
+ private readonly http: HttpClient,
20
+ private readonly apiBase: string,
21
+ private readonly resourcePath: Iri,
22
+ private readonly httpWithCredentialsDefault: boolean,
23
+ ) {
24
+ }
25
+
26
+ getCollection$(query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>> {
27
+ return this.http.get<Collection<T>>(this.resolveUrl(this.resourcePath), {
28
+ ...resolveHttpCallOptions(options, this.httpWithCredentialsDefault),
29
+ params: toHttpParams(query),
30
+ });
31
+ }
32
+
33
+ get$(iri: IriRequired, options?: HttpCallOptions): Observable<T> {
34
+ return this.http.get<T>(this.resolveUrl(iri), resolveHttpCallOptions(options, this.httpWithCredentialsDefault));
35
+ }
36
+
37
+ post$(payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
38
+ return this.http.post<T>(
39
+ this.resolveUrl(this.resourcePath),
40
+ payload,
41
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
42
+ );
43
+ }
44
+
45
+ patch$(iri: IriRequired, changes: Partial<T>, options?: HttpCallOptions): Observable<T> {
46
+ return this.http.patch<T>(
47
+ this.resolveUrl(iri),
48
+ changes,
49
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
50
+ );
51
+ }
52
+
53
+ put$(iri: IriRequired, payload: Partial<T>, options?: HttpCallOptions): Observable<T> {
54
+ return this.http.put<T>(
55
+ this.resolveUrl(iri),
56
+ payload,
57
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
58
+ );
59
+ }
60
+
61
+ delete$(iri: IriRequired, options?: HttpCallOptions): Observable<void> {
62
+ return this.http.delete<void>(
63
+ this.resolveUrl(iri),
64
+ resolveHttpCallOptions(options, this.httpWithCredentialsDefault)
65
+ );
66
+ }
67
+
68
+ request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R> {
69
+ // Low-level escape hatch for non-standard endpoints (custom controllers, uploads, etc.).
70
+ const {method, url} = request;
71
+
72
+ const targetUrl = this.resolveUrl(url ?? this.resourcePath);
73
+ const mergedOptions = buildHttpRequestOptions(request, {withCredentialsDefault: this.httpWithCredentialsDefault});
74
+ return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
75
+ }
76
+
77
+ private resolveUrl(path?: Iri): string {
78
+ const effectivePath = path ?? this.resourcePath;
79
+ if (!effectivePath) throw new Error('ApiPlatformRestRepository: missing url and resourcePath');
80
+ return resolveUrl(this.apiBase, effectivePath);
81
+ }
82
+ }
@@ -0,0 +1,30 @@
1
+ import {HttpCallOptions, HttpRequestConfig} from '../../ports/resource-repository.port';
2
+ import {toHttpParams} from './query-builder';
3
+
4
+ export function resolveHttpCallOptions(options: HttpCallOptions | undefined, withCredentialsDefault: boolean) {
5
+ return {
6
+ headers: options?.headers,
7
+ withCredentials: options?.withCredentials ?? withCredentialsDefault,
8
+ };
9
+ }
10
+
11
+ export function buildHttpRequestOptions(
12
+ req: HttpRequestConfig,
13
+ {withCredentialsDefault}: {withCredentialsDefault: boolean}
14
+ ): Record<string, unknown> {
15
+ const {query, body, headers, responseType, withCredentials, options = {}} = req;
16
+ const mergedOptions: Record<string, unknown> = {...options};
17
+
18
+ if (headers) mergedOptions['headers'] = headers;
19
+ if (query) mergedOptions['params'] = toHttpParams(query);
20
+ if (body !== undefined) mergedOptions['body'] = body;
21
+
22
+ const inheritedResponseType = mergedOptions['responseType'];
23
+ const inheritedWithCredentials = mergedOptions['withCredentials'];
24
+
25
+ mergedOptions['responseType'] = responseType ?? inheritedResponseType ?? 'json';
26
+ mergedOptions['withCredentials'] = withCredentials ?? inheritedWithCredentials ?? withCredentialsDefault;
27
+ mergedOptions['observe'] = 'body';
28
+
29
+ return mergedOptions;
30
+ }
@@ -1,14 +1,14 @@
1
1
  import {HttpParams} from '@angular/common/http';
2
2
  import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
3
3
 
4
- export function toHttpParams(q: AnyQuery | undefined): HttpParams {
5
- if (!q) return new HttpParams();
6
- if (q instanceof HttpParams) return q;
4
+ export function toHttpParams(query: AnyQuery | undefined): HttpParams {
5
+ if (!query) return new HttpParams();
6
+ if (query instanceof HttpParams) return query;
7
7
 
8
8
  const fromObject: Record<string, string | string[]> = {};
9
9
 
10
10
  const consumed = new Set<string>();
11
- const maybeQuery = q as Query;
11
+ const maybeQuery = query as Query;
12
12
  if (maybeQuery.page != null) {
13
13
  fromObject['page'] = String(maybeQuery.page);
14
14
  consumed.add('page');
@@ -18,16 +18,16 @@ export function toHttpParams(q: AnyQuery | undefined): HttpParams {
18
18
  consumed.add('itemsPerPage');
19
19
  }
20
20
 
21
- if (q.filters) {
21
+ if (query.filters) {
22
22
  consumed.add('filters');
23
- for (const [k, v] of Object.entries(q.filters)) {
24
- assign(fromObject, k, v);
23
+ for (const [key, value] of Object.entries(query.filters)) {
24
+ assign(fromObject, key, value);
25
25
  }
26
26
  }
27
27
 
28
- for (const [k, v] of Object.entries(q as Record<string, QueryParamValue>)) {
29
- if (consumed.has(k)) continue;
30
- assign(fromObject, k, v);
28
+ for (const [key, value] of Object.entries(query as Record<string, QueryParamValue>)) {
29
+ if (consumed.has(key)) continue;
30
+ assign(fromObject, key, value);
31
31
  }
32
32
 
33
33
  return new HttpParams({fromObject});
@@ -0,0 +1,255 @@
1
+ import {BehaviorSubject, Observable, Subject} from 'rxjs';
2
+ import {takeUntil} from 'rxjs/operators';
3
+ import {
4
+ BridgeLogger,
5
+ MercureConnectionMode,
6
+ RealtimeDiagnostics,
7
+ } from '../../bridge.types';
8
+ import {RealtimeStatus} from '../../ports/realtime.port';
9
+ import {EventSourceWrapper} from './eventsource-wrapper';
10
+ import {MercureUrlBuilder} from './mercure-url.builder';
11
+
12
+ export type IncomingMessage = {type: string; data: string; lastEventId?: string};
13
+
14
+ type ConnectionScope = 'shared' | 'dedicated';
15
+
16
+ type ManagedConnection = {
17
+ id: string;
18
+ key: string;
19
+ scope: ConnectionScope;
20
+ groupId?: string;
21
+ topics: ReadonlySet<string>;
22
+ urlLength: number;
23
+ wrapper: EventSourceWrapper;
24
+ stop$: Subject<void>;
25
+ status: RealtimeStatus;
26
+ };
27
+
28
+ type ConnectionTarget = Subject<IncomingMessage>;
29
+
30
+ type ConnectionManagerOptions = {
31
+ hubUrl?: string;
32
+ withCredentials: boolean;
33
+ mode: MercureConnectionMode;
34
+ maxUrlLength: number;
35
+ logger?: BridgeLogger;
36
+ destroy$: Observable<void>;
37
+ nextId: (prefix: string) => string;
38
+ };
39
+
40
+ export class MercureConnectionManager {
41
+ private readonly urlBuilder = new MercureUrlBuilder();
42
+ private readonly connections = new Map<string, ManagedConnection>();
43
+ private readonly sharedConnections = new Map<string, ManagedConnection>();
44
+ private readonly dedicatedGroups = new Map<string, Set<string>>();
45
+ private readonly lastEventIdByKey = new Map<string, string>();
46
+
47
+ private readonly statusSubject = new BehaviorSubject<RealtimeStatus>('closed');
48
+ private readonly diagnosticsSubject: BehaviorSubject<RealtimeDiagnostics>;
49
+
50
+ readonly status$ = this.statusSubject.asObservable();
51
+ readonly diagnostics$: Observable<RealtimeDiagnostics>;
52
+
53
+ constructor(private readonly options: ConnectionManagerOptions) {
54
+ this.diagnosticsSubject = new BehaviorSubject<RealtimeDiagnostics>(this.createDiagnostics());
55
+ this.diagnostics$ = this.diagnosticsSubject.asObservable();
56
+ }
57
+
58
+ reconcileShared(topics: Iterable<string>, target: ConnectionTarget): void {
59
+ if (!this.options.hubUrl) {
60
+ this.closeShared();
61
+ return;
62
+ }
63
+
64
+ const desiredConnections = new Map<string, ReadonlySet<string>>();
65
+ for (const partition of this.partitionTopics(topics)) {
66
+ const partitionTopics = new Set(partition);
67
+ desiredConnections.set(this.connectionKey(partitionTopics, 'shared'), partitionTopics);
68
+ }
69
+
70
+ for (const [key, connection] of [...this.sharedConnections.entries()]) {
71
+ if (!desiredConnections.has(key)) this.close(connection.id);
72
+ }
73
+
74
+ for (const [key, partitionTopics] of desiredConnections) {
75
+ if (this.sharedConnections.has(key)) continue;
76
+ const connection = this.open({key, scope: 'shared', topics: partitionTopics, target});
77
+ this.sharedConnections.set(key, connection);
78
+ }
79
+ }
80
+
81
+ openDedicated(groupId: string, topics: Iterable<string>, target: ConnectionTarget): void {
82
+ this.dedicatedGroups.set(groupId, new Set());
83
+
84
+ for (const partition of this.partitionTopics(topics)) {
85
+ const partitionTopics = new Set(partition);
86
+ const key = this.connectionKey(partitionTopics, 'dedicated', groupId);
87
+ const connection = this.open({key, scope: 'dedicated', groupId, topics: partitionTopics, target});
88
+ this.dedicatedGroups.get(groupId)?.add(connection.id);
89
+ }
90
+ }
91
+
92
+ closeShared(): void {
93
+ for (const connection of [...this.sharedConnections.values()]) this.close(connection.id);
94
+ }
95
+
96
+ closeDedicated(groupId: string): void {
97
+ const connectionIds = this.dedicatedGroups.get(groupId);
98
+ if (!connectionIds) return;
99
+
100
+ for (const connectionId of [...connectionIds]) this.close(connectionId);
101
+ this.dedicatedGroups.delete(groupId);
102
+ }
103
+
104
+ closeAll(): void {
105
+ for (const connection of [...this.connections.values()]) this.close(connection.id);
106
+ this.sharedConnections.clear();
107
+ this.dedicatedGroups.clear();
108
+ this.updateStatus();
109
+ }
110
+
111
+ complete(): void {
112
+ this.statusSubject.complete();
113
+ this.diagnosticsSubject.complete();
114
+ }
115
+
116
+ private partitionTopics(topics: Iterable<string>): string[][] {
117
+ if (!this.options.hubUrl) return [];
118
+ return this.urlBuilder.partitionTopics(
119
+ this.options.hubUrl,
120
+ topics,
121
+ this.options.maxUrlLength,
122
+ this.options.mode === 'auto'
123
+ );
124
+ }
125
+
126
+ private open(params: {
127
+ key: string;
128
+ scope: ConnectionScope;
129
+ topics: ReadonlySet<string>;
130
+ target: ConnectionTarget;
131
+ groupId?: string;
132
+ }): ManagedConnection {
133
+ const {hubUrl, withCredentials, logger, destroy$, nextId} = this.options;
134
+ if (!hubUrl) throw new Error('Mercure hub URL is required to open a realtime connection.');
135
+
136
+ const lastEventId = this.lastEventIdByKey.get(params.key);
137
+ const url = this.urlBuilder.build(hubUrl, params.topics, lastEventId);
138
+
139
+ logger?.debug?.('[Mercure] connect', {
140
+ mode: this.options.mode,
141
+ scope: params.scope,
142
+ key: params.key,
143
+ urlLength: url.length,
144
+ topics: [...params.topics],
145
+ lastEventId,
146
+ });
147
+
148
+ const wrapper = new EventSourceWrapper(url, {withCredentials}, logger);
149
+ const stop$ = new Subject<void>();
150
+ const connection: ManagedConnection = {
151
+ id: nextId('conn'),
152
+ key: params.key,
153
+ scope: params.scope,
154
+ groupId: params.groupId,
155
+ topics: params.topics,
156
+ urlLength: url.length,
157
+ wrapper,
158
+ stop$,
159
+ status: 'connecting',
160
+ };
161
+
162
+ this.connections.set(connection.id, connection);
163
+
164
+ wrapper.status$
165
+ .pipe(takeUntil(stop$), takeUntil(destroy$))
166
+ .subscribe((status) => {
167
+ connection.status = status;
168
+ this.updateStatus();
169
+ });
170
+
171
+ wrapper.events$
172
+ .pipe(takeUntil(stop$), takeUntil(destroy$))
173
+ .subscribe((event) => {
174
+ if (event.lastEventId) this.lastEventIdByKey.set(params.key, event.lastEventId);
175
+ params.target.next({type: event.type, data: event.data, lastEventId: event.lastEventId});
176
+ });
177
+
178
+ try {
179
+ wrapper.open();
180
+ this.updateStatus();
181
+ return connection;
182
+ } catch (error) {
183
+ this.close(connection.id);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ private close(connectionId: string): void {
189
+ const connection = this.connections.get(connectionId);
190
+ if (!connection) return;
191
+
192
+ connection.status = 'closed';
193
+ connection.wrapper.close();
194
+ connection.stop$.next();
195
+ connection.stop$.complete();
196
+ this.connections.delete(connectionId);
197
+
198
+ if (connection.scope === 'shared') this.sharedConnections.delete(connection.key);
199
+ if (connection.scope === 'dedicated' && connection.groupId) {
200
+ const group = this.dedicatedGroups.get(connection.groupId);
201
+ group?.delete(connection.id);
202
+ if (group?.size === 0) this.dedicatedGroups.delete(connection.groupId);
203
+ this.lastEventIdByKey.delete(connection.key);
204
+ }
205
+
206
+ this.updateStatus();
207
+ }
208
+
209
+ private connectionKey(topics: ReadonlySet<string>, scope: ConnectionScope, groupId = ''): string {
210
+ const {hubUrl = '', withCredentials, mode} = this.options;
211
+ return [
212
+ scope,
213
+ groupId,
214
+ hubUrl,
215
+ withCredentials ? '1' : '0',
216
+ mode,
217
+ [...topics].sort().join('|'),
218
+ ].join('::');
219
+ }
220
+
221
+ private updateStatus(): void {
222
+ const statuses = [...this.connections.values()].map((connection) => connection.status);
223
+ this.statusSubject.next(aggregateStatus(statuses));
224
+ this.diagnosticsSubject.next(this.createDiagnostics());
225
+ }
226
+
227
+ private createDiagnostics(): RealtimeDiagnostics {
228
+ const connections = [...this.connections.values()]
229
+ .map((connection) => ({
230
+ id: connection.id,
231
+ scope: connection.scope,
232
+ status: connection.status,
233
+ topicCount: connection.topics.size,
234
+ topics: [...connection.topics].sort(),
235
+ urlLength: connection.urlLength,
236
+ }))
237
+ .sort((left, right) => left.scope.localeCompare(right.scope) || left.id.localeCompare(right.id));
238
+ const sharedConnections = connections.filter((connection) => connection.scope === 'shared').length;
239
+
240
+ return {
241
+ mode: this.options.mode,
242
+ maxUrlLength: this.options.maxUrlLength,
243
+ totalConnections: connections.length,
244
+ sharedConnections,
245
+ dedicatedConnections: connections.length - sharedConnections,
246
+ connections,
247
+ };
248
+ }
249
+ }
250
+
251
+ function aggregateStatus(statuses: RealtimeStatus[]): RealtimeStatus {
252
+ if (statuses.includes('connected')) return 'connected';
253
+ if (statuses.includes('connecting')) return 'connecting';
254
+ return 'closed';
255
+ }
@@ -0,0 +1,82 @@
1
+ import {BridgeLogger} from '../../bridge.types';
2
+ import {SubscribeFilter} from '../../ports/realtime.port';
3
+ import {Item} from '../../ports/resource-repository.port';
4
+ import {MercureTopicMapper} from './mercure-topic.mapper';
5
+
6
+ export function parseMercurePayload(raw: string, logger?: BridgeLogger): Item | undefined {
7
+ try {
8
+ const value: unknown = JSON.parse(raw);
9
+ return isRecord(value) ? value as Item : undefined;
10
+ } catch {
11
+ logger?.debug?.('[Mercure] invalid JSON payload ignored', {raw});
12
+ return undefined;
13
+ }
14
+ }
15
+
16
+ export function createPayloadTopicMatcher(
17
+ iris: string[],
18
+ filter: SubscribeFilter | undefined,
19
+ topicMapper: MercureTopicMapper
20
+ ): (payload: Item) => string | undefined {
21
+ const subscribedTopics = iris.map((iri) => normalizeIri(topicMapper.toPayloadIri(iri)));
22
+ const fieldPaths = [
23
+ ...(filter?.fields?.filter((field): field is string => typeof field === 'string' && field.length > 0) ?? []),
24
+ ...(typeof filter?.field === 'string' && filter.field.length > 0 ? [filter.field] : []),
25
+ ];
26
+ const includeSelf = filter?.includeSelf ?? fieldPaths.length === 0;
27
+
28
+ return (payload) => {
29
+ for (const fieldPath of fieldPaths) {
30
+ const relationIris = readRelationIris(payload, fieldPath)
31
+ .map((iri) => normalizeIri(topicMapper.toPayloadIri(iri)));
32
+ for (const relationIri of relationIris) {
33
+ const match = findClosestTopic(relationIri, subscribedTopics);
34
+ if (match) return match;
35
+ }
36
+ }
37
+
38
+ if (!includeSelf) return undefined;
39
+
40
+ const payloadIri = payload['@id'];
41
+ if (typeof payloadIri !== 'string') return undefined;
42
+ return findClosestTopic(normalizeIri(topicMapper.toPayloadIri(payloadIri)), subscribedTopics);
43
+ };
44
+ }
45
+
46
+ function readRelationIris(payload: Item, path: string): string[] {
47
+ const value = path
48
+ .split('.')
49
+ .filter(Boolean)
50
+ .reduce<unknown>((current, key) => isIndexable(current) ? current[key] : undefined, payload);
51
+
52
+ return extractIris(value);
53
+ }
54
+
55
+ function extractIris(value: unknown): string[] {
56
+ if (!value) return [];
57
+ if (typeof value === 'string') return value.length > 0 ? [value] : [];
58
+ if (isIndexable(value) && typeof value['@id'] === 'string') return [value['@id']];
59
+ if (Array.isArray(value)) return value.flatMap(extractIris);
60
+ return [];
61
+ }
62
+
63
+ function findClosestTopic(candidate: string, subscribedTopics: string[]): string | undefined {
64
+ let closest: string | undefined;
65
+ for (const topic of subscribedTopics) {
66
+ if (candidate !== topic && !candidate.startsWith(`${topic}/`)) continue;
67
+ if (!closest || topic.length > closest.length) closest = topic;
68
+ }
69
+ return closest;
70
+ }
71
+
72
+ function normalizeIri(iri: string): string {
73
+ return iri.endsWith('/') ? iri.slice(0, -1) : iri;
74
+ }
75
+
76
+ function isRecord(value: unknown): value is Record<string, unknown> {
77
+ return !!value && typeof value === 'object' && !Array.isArray(value);
78
+ }
79
+
80
+ function isIndexable(value: unknown): value is Record<string, unknown> {
81
+ return !!value && typeof value === 'object';
82
+ }