@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.
- package/README.md +19 -2
- package/cli.js +21 -37
- package/package.json +3 -3
- package/src/README.md +9 -0
- package/{tools/app → src/application}/bridge-workflow.js +15 -13
- package/src/application/generate-bridge.js +50 -0
- package/{tools/app → src/application}/run-build.js +1 -1
- package/{tools/app → src/application}/run-dev.js +30 -36
- package/{tools/app → src/application}/run-generate.js +3 -3
- package/{tools → src}/domain/bridge-options.js +8 -21
- package/src/domain/list-values.js +6 -0
- package/src/generators/bridge/write-bridge-files.js +100 -0
- package/src/generators/models/interface-schema.js +116 -0
- package/src/generators/models/model-selection.js +148 -0
- package/src/generators/models/oas-contract.js +170 -0
- package/{tools/generator → src/generators}/models/oas-schema-name.js +6 -15
- package/src/generators/models/openapi-to-models.js +136 -0
- package/{tools/generator → src/generators}/models/schema-utils.js +27 -3
- package/src/generators/models/templates/type-alias.hbs +2 -0
- package/src/generators/models/type-expression.js +65 -0
- package/src/generators/models/type-resolver.js +240 -0
- package/src/generators/models/utils.js +89 -0
- package/src/generators/models/write-model-files.js +55 -0
- package/src/infrastructure/dev-project.js +48 -0
- package/{tools/infra → src/infrastructure}/exec.js +3 -2
- package/src/infrastructure/local-package-install.js +104 -0
- package/src/infrastructure/spec-loader.js +65 -0
- package/{tools/infra → src/infrastructure}/standalone-workspace.js +31 -29
- package/templates/{_lib_template → angular-bridge}/README.md +22 -4
- package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +82 -0
- package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +30 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/bridge/rest/query-builder.ts +10 -10
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +255 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +82 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +39 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +358 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/ref-count-topic.registry.ts +3 -12
- package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +76 -0
- package/templates/angular-bridge/src/lib/bridge-options.ts +59 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/facades/bridge.facade.ts +52 -53
- package/templates/{_lib_template → angular-bridge}/src/lib/facades/facade.factory.ts +3 -4
- package/templates/angular-bridge/src/lib/facades/facade.interface.ts +13 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/facades/resource.facade.ts +24 -31
- package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +33 -0
- package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +65 -0
- package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +37 -0
- package/templates/angular-bridge/src/lib/interceptors/scoped.interceptor.ts +9 -0
- package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +46 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/ports/realtime.port.ts +6 -2
- package/templates/{_lib_template → angular-bridge}/src/lib/ports/resource-repository.port.ts +7 -7
- package/templates/angular-bridge/src/lib/provide-bridge.ts +98 -0
- package/templates/{_lib_template → angular-bridge}/src/lib/tokens.ts +9 -3
- package/templates/angular-bridge/src/lib/utils/iri.ts +6 -0
- package/templates/angular-bridge/src/lib/utils/request-url.ts +31 -0
- package/templates/{_lib_template → angular-bridge}/src/public-api.ts +2 -0
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +0 -84
- package/templates/_lib_template/src/lib/bridge/rest/http-request.options.ts +0 -21
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +0 -17
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +0 -806
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +0 -31
- package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +0 -53
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +0 -49
- package/templates/_lib_template/src/lib/interceptors/singleflight.interceptor.ts +0 -55
- package/templates/_lib_template/src/lib/provide-bridge.ts +0 -170
- package/tools/build.js +0 -1
- package/tools/dev.js +0 -1
- package/tools/domain/paths.js +0 -10
- package/tools/generate.js +0 -1
- package/tools/generator/models/oas-contract.js +0 -159
- package/tools/generator/models/openapi-to-models.js +0 -240
- package/tools/generator/models/type-resolver.js +0 -107
- package/tools/generator/models/utils.js +0 -62
- package/tools/infra/dev-support.js +0 -189
- package/tools/infra/generate/bridge.js +0 -212
- package/tools/infra/spec-loader.js +0 -59
- /package/{tools → src}/domain/lib-name.js +0 -0
- /package/{tools/generator → src/generators}/models/handlebars.js +0 -0
- /package/{tools/generator → src/generators}/models/templates/index.hbs +0 -0
- /package/{tools/generator → src/generators}/models/templates/model.hbs +0 -0
- /package/{tools/infra → src/infrastructure}/json.js +0 -0
- /package/{tools/infra → src/infrastructure}/logger.js +0 -0
- /package/templates/{_lib_template → angular-bridge}/ng-package.json +0 -0
- /package/templates/{_lib_template → angular-bridge}/package.json +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/eventsource-wrapper.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/lib/bridge.types.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/lib/interceptors/bridge-debug.interceptor.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/lib/utils/url.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/src/models/index.ts +0 -0
- /package/templates/{_lib_template → angular-bridge}/tsconfig.lib.json +0 -0
- /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
|
|
9
|
-
await fs.mkdir(
|
|
8
|
+
async function ensureDirectory(directory) {
|
|
9
|
+
await fs.mkdir(directory, { recursive: true });
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
async function exists(
|
|
12
|
+
async function exists(filePath) {
|
|
13
13
|
try {
|
|
14
|
-
await fs.stat(
|
|
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':
|
|
29
|
-
'@angular/compiler':
|
|
30
|
-
'@angular/compiler-cli':
|
|
31
|
-
'@angular/core':
|
|
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
|
|
66
|
-
await
|
|
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
|
|
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
|
|
90
|
+
const packageJsonPath = path.join(repoRoot, 'package.json');
|
|
92
91
|
try {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const angularVersion = readDependencyVersion(
|
|
96
|
-
const ngPackagrVersion = readDependencyVersion(
|
|
97
|
-
const typescriptVersion = readDependencyVersion(
|
|
98
|
-
const rxjsVersion = readDependencyVersion(
|
|
99
|
-
const tslibVersion = readDependencyVersion(
|
|
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(
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const value =
|
|
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 = (
|
|
132
|
+
const canResolve = (packageId) => {
|
|
131
133
|
try {
|
|
132
|
-
requireFromWs.resolve(
|
|
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
|
|
155
|
+
const npmCommand = 'npm';
|
|
154
156
|
const args = ['install', '--silent', '--no-progress'];
|
|
155
|
-
const code = await runCommand(
|
|
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
|
-
|
|
74
|
-
- `
|
|
75
|
-
- `
|
|
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
|
-
|
|
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(
|
|
5
|
-
if (!
|
|
6
|
-
if (
|
|
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 =
|
|
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 (
|
|
21
|
+
if (query.filters) {
|
|
22
22
|
consumed.add('filters');
|
|
23
|
-
for (const [
|
|
24
|
-
assign(fromObject,
|
|
23
|
+
for (const [key, value] of Object.entries(query.filters)) {
|
|
24
|
+
assign(fromObject, key, value);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
for (const [
|
|
29
|
-
if (consumed.has(
|
|
30
|
-
assign(fromObject,
|
|
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
|
+
}
|