@obsidiane/meridiane 2.0.10 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +19 -2
  2. package/cli.js +21 -37
  3. package/package.json +3 -3
  4. package/src/README.md +9 -0
  5. package/{tools/app → src/application}/bridge-workflow.js +15 -13
  6. package/src/application/generate-bridge.js +50 -0
  7. package/{tools/app → src/application}/run-build.js +1 -1
  8. package/{tools/app → src/application}/run-dev.js +30 -36
  9. package/{tools/app → src/application}/run-generate.js +3 -3
  10. package/{tools → src}/domain/bridge-options.js +8 -21
  11. package/src/domain/list-values.js +6 -0
  12. package/src/generators/bridge/write-bridge-files.js +100 -0
  13. package/src/generators/models/interface-schema.js +116 -0
  14. package/src/generators/models/model-selection.js +148 -0
  15. package/src/generators/models/oas-contract.js +170 -0
  16. package/{tools/generator → src/generators}/models/oas-schema-name.js +6 -15
  17. package/src/generators/models/openapi-to-models.js +136 -0
  18. package/{tools/generator → src/generators}/models/schema-utils.js +27 -3
  19. package/src/generators/models/templates/type-alias.hbs +2 -0
  20. package/src/generators/models/type-expression.js +65 -0
  21. package/src/generators/models/type-resolver.js +240 -0
  22. package/src/generators/models/utils.js +89 -0
  23. package/src/generators/models/write-model-files.js +55 -0
  24. package/src/infrastructure/dev-project.js +48 -0
  25. package/{tools/infra → src/infrastructure}/exec.js +3 -2
  26. package/src/infrastructure/local-package-install.js +104 -0
  27. package/src/infrastructure/spec-loader.js +65 -0
  28. package/{tools/infra → src/infrastructure}/standalone-workspace.js +31 -29
  29. package/templates/{_lib_template → angular-bridge}/README.md +9 -0
  30. package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +82 -0
  31. package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +30 -0
  32. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/rest/query-builder.ts +10 -10
  33. package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +255 -0
  34. package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +82 -0
  35. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +39 -0
  36. package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +358 -0
  37. package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/ref-count-topic.registry.ts +3 -12
  38. package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +76 -0
  39. package/templates/angular-bridge/src/lib/bridge-options.ts +59 -0
  40. package/templates/{_lib_template → angular-bridge}/src/lib/facades/bridge.facade.ts +50 -51
  41. package/templates/{_lib_template → angular-bridge}/src/lib/facades/facade.factory.ts +0 -1
  42. package/templates/angular-bridge/src/lib/facades/facade.interface.ts +13 -0
  43. package/templates/{_lib_template → angular-bridge}/src/lib/facades/resource.facade.ts +24 -31
  44. package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +33 -0
  45. package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +65 -0
  46. package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +37 -0
  47. package/templates/angular-bridge/src/lib/interceptors/scoped.interceptor.ts +9 -0
  48. package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +46 -0
  49. package/templates/{_lib_template → angular-bridge}/src/lib/ports/realtime.port.ts +6 -2
  50. package/templates/{_lib_template → angular-bridge}/src/lib/ports/resource-repository.port.ts +7 -7
  51. package/templates/angular-bridge/src/lib/provide-bridge.ts +98 -0
  52. package/templates/angular-bridge/src/lib/utils/iri.ts +6 -0
  53. package/templates/angular-bridge/src/lib/utils/request-url.ts +31 -0
  54. package/templates/{_lib_template → angular-bridge}/src/public-api.ts +2 -0
  55. package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +0 -84
  56. package/templates/_lib_template/src/lib/bridge/rest/http-request.options.ts +0 -21
  57. package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +0 -17
  58. package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +0 -806
  59. package/templates/_lib_template/src/lib/facades/facade.interface.ts +0 -31
  60. package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +0 -53
  61. package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +0 -49
  62. package/templates/_lib_template/src/lib/interceptors/singleflight.interceptor.ts +0 -55
  63. package/templates/_lib_template/src/lib/provide-bridge.ts +0 -186
  64. package/tools/build.js +0 -1
  65. package/tools/dev.js +0 -1
  66. package/tools/domain/paths.js +0 -10
  67. package/tools/generate.js +0 -1
  68. package/tools/generator/models/oas-contract.js +0 -159
  69. package/tools/generator/models/openapi-to-models.js +0 -240
  70. package/tools/generator/models/type-resolver.js +0 -107
  71. package/tools/generator/models/utils.js +0 -62
  72. package/tools/infra/dev-support.js +0 -189
  73. package/tools/infra/generate/bridge.js +0 -212
  74. package/tools/infra/spec-loader.js +0 -59
  75. /package/{tools → src}/domain/lib-name.js +0 -0
  76. /package/{tools/generator → src/generators}/models/handlebars.js +0 -0
  77. /package/{tools/generator → src/generators}/models/templates/index.hbs +0 -0
  78. /package/{tools/generator → src/generators}/models/templates/model.hbs +0 -0
  79. /package/{tools/infra → src/infrastructure}/json.js +0 -0
  80. /package/{tools/infra → src/infrastructure}/logger.js +0 -0
  81. /package/templates/{_lib_template → angular-bridge}/ng-package.json +0 -0
  82. /package/templates/{_lib_template → angular-bridge}/package.json +0 -0
  83. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/eventsource-wrapper.ts +0 -0
  84. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -0
  85. /package/templates/{_lib_template → angular-bridge}/src/lib/bridge.types.ts +0 -0
  86. /package/templates/{_lib_template → angular-bridge}/src/lib/interceptors/bridge-debug.interceptor.ts +0 -0
  87. /package/templates/{_lib_template → angular-bridge}/src/lib/tokens.ts +0 -0
  88. /package/templates/{_lib_template → angular-bridge}/src/lib/utils/url.ts +0 -0
  89. /package/templates/{_lib_template → angular-bridge}/src/models/index.ts +0 -0
  90. /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.json +0 -0
  91. /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.prod.json +0 -0
@@ -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')))) {
@@ -67,6 +67,10 @@ export class AppModule {}
67
67
  - `debug` (logs runtime) ;
68
68
  - `extraInterceptors` (interceptors Angular additionnels).
69
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
+
70
74
  ### Cookies / `withCredentials`
71
75
 
72
76
  Le bridge peut envoyer des cookies (sessions) côté HTTP et SSE.
@@ -105,6 +109,11 @@ provideBridge({
105
109
  - `{ type: 'bearer', getToken }` (sync ou async),
106
110
  - ou un `HttpInterceptorFn` custom.
107
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
+
108
117
  ```ts
109
118
  import {provideBridge} from '__PACKAGE_NAME__';
110
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
+ }
@@ -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
+ }