@obsidiane/meridiane 0.0.6
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 +60 -0
- package/cli.js +50 -0
- package/models.config.example.js +24 -0
- package/package.json +37 -0
- package/templates/_lib_template/ng-package.json +7 -0
- package/templates/_lib_template/package.json +12 -0
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +9 -0
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +101 -0
- package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +43 -0
- package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +74 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +17 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +206 -0
- package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +43 -0
- package/templates/_lib_template/src/lib/facades/facade.factory.ts +36 -0
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +35 -0
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +115 -0
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +50 -0
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +34 -0
- package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +79 -0
- package/templates/_lib_template/src/lib/provide-bridge.ts +35 -0
- package/templates/_lib_template/src/lib/tokens.ts +7 -0
- package/templates/_lib_template/src/lib/utils/utils.ts +21 -0
- package/templates/_lib_template/src/models/index.ts +2 -0
- package/templates/_lib_template/src/public-api.ts +8 -0
- package/templates/_lib_template/tsconfig.lib.json +18 -0
- package/templates/_lib_template/tsconfig.lib.prod.json +11 -0
- package/templates/_lib_template/tsconfig.spec.json +14 -0
- package/tools/generate-lib.js +136 -0
- package/tools/generate-models.js +121 -0
- package/tools/generator/models/handlebars.js +15 -0
- package/tools/generator/models/naming.js +20 -0
- package/tools/generator/models/openapi-to-models.js +98 -0
- package/tools/generator/models/schema-utils.js +125 -0
- package/tools/generator/models/templates/index.hbs +3 -0
- package/tools/generator/models/templates/model.hbs +10 -0
- package/tools/generator/models/type-resolver.js +96 -0
- package/tools/generator/models/utils.js +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @obsidiane/meridiane
|
|
2
|
+
|
|
3
|
+
CLI pour générer une librairie Angular “bridge” (Symfony / API Platform / Mercure) et générer des modèles TypeScript depuis une spec OpenAPI.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @obsidiane/meridiane
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Ou en global :
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -g @obsidiane/meridiane
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Générer une librairie Angular (dans projects/<lib-name>)
|
|
21
|
+
npx meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
22
|
+
|
|
23
|
+
# Générer des modèles depuis OpenAPI
|
|
24
|
+
npx meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [--out=<dir>] [--item-import=../lib/ports/resource-repository.port] [--required-mode=all-optional|spec] [--no-index]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`url-registry` est optionnel. Recommandé : gérer le registry via `.npmrc` / variables CI plutôt que de le passer au générateur.
|
|
28
|
+
|
|
29
|
+
## Exemple CI (générer + build + publish)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm ci
|
|
33
|
+
npx -y @obsidiane/meridiane@0.1.0 lib backend-bridge @acme/backend-bridge 0.1.0
|
|
34
|
+
npx -y @obsidiane/meridiane@0.1.0 models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
|
|
35
|
+
npx ng build backend-bridge
|
|
36
|
+
cd dist/backend-bridge
|
|
37
|
+
npm publish
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Si votre workspace ne contient pas `ng-packagr` (souvent le cas dans une app Angular “pure”), installez-le :
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm i -D ng-packagr
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration (models.config.js)
|
|
47
|
+
|
|
48
|
+
Le générateur de modèles charge automatiquement un fichier `models.config.js` s’il existe dans le répertoire courant (CWD).
|
|
49
|
+
|
|
50
|
+
Copie rapide (après installation) :
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cp node_modules/@obsidiane/meridiane/models.config.example.js ./models.config.js
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Sans installation préalable :
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx -y @obsidiane/meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
60
|
+
```
|
package/cli.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
function runTool(scriptName, args) {
|
|
10
|
+
const scriptPath = path.join(__dirname, 'tools', scriptName);
|
|
11
|
+
const child = spawn(process.execPath, [scriptPath, ...args], {
|
|
12
|
+
stdio: 'inherit',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
child.on('exit', (code) => {
|
|
16
|
+
process.exit(code ?? 0);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`
|
|
22
|
+
Usage:
|
|
23
|
+
meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
24
|
+
meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [options]
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
npx -y @obsidiane/meridiane lib backend-bridge @acme/backend-bridge 0.1.0
|
|
28
|
+
npx -y @obsidiane/meridiane models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [, , cmd, ...rest] = process.argv;
|
|
33
|
+
|
|
34
|
+
switch (cmd) {
|
|
35
|
+
case 'lib':
|
|
36
|
+
runTool('generate-lib.js', rest);
|
|
37
|
+
break;
|
|
38
|
+
case 'models':
|
|
39
|
+
runTool('generate-models.js', rest);
|
|
40
|
+
break;
|
|
41
|
+
case '-h':
|
|
42
|
+
case '--help':
|
|
43
|
+
case undefined:
|
|
44
|
+
printHelp();
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
console.error(`Unknown command: ${cmd}`);
|
|
48
|
+
printHelp();
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Exemple de configuration pour la génération des modèles.
|
|
2
|
+
// Copiez ce fichier en `models.config.js` dans le dossier où vous lancez la commande (`meridiane models`).
|
|
3
|
+
// Format CommonJS pour éviter les warnings Node dans la plupart des workspaces Angular.
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
// Dossier de sortie (relatif au CWD)
|
|
7
|
+
outDir: 'projects/backend-bridge/src/models',
|
|
8
|
+
|
|
9
|
+
// Chemin d'import de `Item` dans les templates (relatif au fichier généré)
|
|
10
|
+
itemImportPath: '../lib/ports/resource-repository.port',
|
|
11
|
+
|
|
12
|
+
// Contrôle des propriétés optionnelles
|
|
13
|
+
// - 'all-optional': toutes les props sont optionnelles
|
|
14
|
+
// - 'spec': respecte le tableau `required` de la spec
|
|
15
|
+
requiredMode: 'all-optional',
|
|
16
|
+
|
|
17
|
+
// Préférence entre variantes de schémas (.jsonld, .jsonapi ou aucune)
|
|
18
|
+
// Valeurs possibles: 'jsonld' | 'jsonapi' | 'none'
|
|
19
|
+
preferFlavor: 'jsonld',
|
|
20
|
+
|
|
21
|
+
// Hydra: regex pour les schémas de base à ignorer (merge allOf / filtrage)
|
|
22
|
+
// Peut être une RegExp ou une string passée à `new RegExp(...)`
|
|
23
|
+
hydraBaseRegex: /^Hydra(?:Item|Collection)BaseSchema$/,
|
|
24
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obsidiane/meridiane",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/obsidiane-lab/meridiane"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"pack:check": "npm_config_cache=./.npm-cache npm pack --dry-run"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"meridiane": "cli.js",
|
|
18
|
+
"meridiane-generate-lib": "tools/generate-lib.js",
|
|
19
|
+
"meridiane-generate-models": "tools/generate-models.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"cli.js",
|
|
23
|
+
"tools/**",
|
|
24
|
+
"templates/**",
|
|
25
|
+
"models.config.example.js",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"fs-extra": "^11.3.1",
|
|
33
|
+
"handlebars": "^4.7.8",
|
|
34
|
+
"openapi-typescript-codegen": "^0.29.0",
|
|
35
|
+
"tslib": "^2.3.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {HttpClient} from '@angular/common/http';
|
|
2
|
+
import {Observable} from 'rxjs';
|
|
3
|
+
import {toHttpParams} from './query-builder';
|
|
4
|
+
import {
|
|
5
|
+
Collection,
|
|
6
|
+
CreateCommand,
|
|
7
|
+
HttpRequestConfig,
|
|
8
|
+
Iri, Item,
|
|
9
|
+
Query,
|
|
10
|
+
ResourceRepository,
|
|
11
|
+
UpdateCommand
|
|
12
|
+
} from '../../ports/resource-repository.port';
|
|
13
|
+
import {CredentialsPolicy} from '../credentials.policy';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
|
|
17
|
+
private readonly credentialsPolicy: CredentialsPolicy;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly http: HttpClient,
|
|
21
|
+
private readonly apiBase: string,
|
|
22
|
+
private readonly resourcePath: Iri,
|
|
23
|
+
readonly init: string,
|
|
24
|
+
) {
|
|
25
|
+
this.credentialsPolicy = new CredentialsPolicy(init);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
list$(query?: Query): Observable<Collection<T>> {
|
|
29
|
+
return this.http.get<any>(`${this.apiBase}${this.resourcePath}`, {
|
|
30
|
+
params: toHttpParams(query),
|
|
31
|
+
withCredentials: this.credentialsPolicy.withCredentials()
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get$(iri: Iri): Observable<T> {
|
|
36
|
+
return this.http.get<T>(`${this.apiBase}${iri}`, {
|
|
37
|
+
withCredentials: this.credentialsPolicy.withCredentials()
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
create$(cmd: CreateCommand<T>): Observable<T> {
|
|
42
|
+
return this.http.post<T>(`${this.apiBase}${this.resourcePath}`, cmd.payload, {
|
|
43
|
+
withCredentials: this.credentialsPolicy.withCredentials()
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
update$(cmd: UpdateCommand<T>): Observable<T> {
|
|
48
|
+
return this.http.patch<T>(`${this.apiBase}${cmd.iri}`, cmd.changes, {
|
|
49
|
+
withCredentials: this.credentialsPolicy.withCredentials()
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
delete$(iri: Iri): Observable<void> {
|
|
54
|
+
return this.http.delete<void>(`${this.apiBase}${iri}`, {
|
|
55
|
+
withCredentials: this.credentialsPolicy.withCredentials()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
|
|
60
|
+
const {
|
|
61
|
+
method,
|
|
62
|
+
url,
|
|
63
|
+
query,
|
|
64
|
+
body,
|
|
65
|
+
headers,
|
|
66
|
+
responseType,
|
|
67
|
+
withCredentials,
|
|
68
|
+
options = {}
|
|
69
|
+
} = req;
|
|
70
|
+
|
|
71
|
+
const mergedOptions: any = {...options};
|
|
72
|
+
const targetUrl = this.resolveUrl(url ?? this.resourcePath);
|
|
73
|
+
|
|
74
|
+
const computedResponseType = responseType ?? mergedOptions.responseType ?? 'json';
|
|
75
|
+
mergedOptions.responseType = computedResponseType as any;
|
|
76
|
+
|
|
77
|
+
const credentials = withCredentials ?? mergedOptions.withCredentials ?? this.credentialsPolicy.withCredentials();
|
|
78
|
+
mergedOptions.withCredentials = credentials;
|
|
79
|
+
|
|
80
|
+
if (headers) mergedOptions.headers = headers;
|
|
81
|
+
if (query) mergedOptions.params = toHttpParams(query as any);
|
|
82
|
+
if (body !== undefined) mergedOptions.body = body;
|
|
83
|
+
|
|
84
|
+
mergedOptions.observe = 'body';
|
|
85
|
+
return this.http.request<R>(method, targetUrl, mergedOptions as { observe: 'body' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private resolveUrl(path?: Iri): string {
|
|
89
|
+
const effectivePath = path ?? this.resourcePath;
|
|
90
|
+
if (!effectivePath) throw new Error('ApiPlatformRestRepository.resolveUrl: missing url and resourcePath');
|
|
91
|
+
if (/^https?:\/\//i.test(effectivePath)) return effectivePath;
|
|
92
|
+
if (effectivePath.startsWith('//')) return effectivePath;
|
|
93
|
+
return joinUrl(this.apiBase, effectivePath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function joinUrl(base: string, path: string): string {
|
|
98
|
+
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
99
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
100
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {HttpParams} from '@angular/common/http';
|
|
2
|
+
import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
|
|
3
|
+
|
|
4
|
+
export function toHttpParams(q: AnyQuery | undefined): HttpParams {
|
|
5
|
+
if (!q) return new HttpParams();
|
|
6
|
+
if (q instanceof HttpParams) return q;
|
|
7
|
+
|
|
8
|
+
const fromObject: Record<string, string | string[]> = {};
|
|
9
|
+
|
|
10
|
+
const consumed = new Set<string>();
|
|
11
|
+
const maybeQuery = q as Query;
|
|
12
|
+
if (maybeQuery.page != null) {
|
|
13
|
+
fromObject['page'] = String(maybeQuery.page);
|
|
14
|
+
consumed.add('page');
|
|
15
|
+
}
|
|
16
|
+
if (maybeQuery.itemsPerPage != null) {
|
|
17
|
+
fromObject['itemsPerPage'] = String(maybeQuery.itemsPerPage);
|
|
18
|
+
consumed.add('itemsPerPage');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (q.filters) {
|
|
22
|
+
consumed.add('filters');
|
|
23
|
+
for (const [k, v] of Object.entries(q.filters)) {
|
|
24
|
+
assign(fromObject, k, v);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
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);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new HttpParams({fromObject});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assign(target: Record<string, string | string[]>, key: string, value: QueryParamValue | undefined) {
|
|
37
|
+
if (value == null) return;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
target[key] = value.map(String);
|
|
40
|
+
} else {
|
|
41
|
+
target[key] = String(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {ReplaySubject, Subject} from 'rxjs';
|
|
2
|
+
import {SseEvent, SseOptions, RealtimeStatus} from '../../ports/realtime.port';
|
|
3
|
+
|
|
4
|
+
export class EventSourceWrapper {
|
|
5
|
+
private es?: EventSource;
|
|
6
|
+
|
|
7
|
+
private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
|
|
8
|
+
private readonly eventSub = new Subject<SseEvent>();
|
|
9
|
+
|
|
10
|
+
readonly status$ = this.statusSub.asObservable();
|
|
11
|
+
readonly events$ = this.eventSub.asObservable();
|
|
12
|
+
|
|
13
|
+
/** Activer pour conversations local */
|
|
14
|
+
private readonly debug = false;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly url: string,
|
|
18
|
+
private readonly opts: SseOptions = {}
|
|
19
|
+
) {
|
|
20
|
+
this.setState('closed');
|
|
21
|
+
this.log('[SSE] initialized', url, opts);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
open(): void {
|
|
25
|
+
if (this.es) {
|
|
26
|
+
this.log('[SSE] open() ignored: already open');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.setState('connecting');
|
|
31
|
+
this.log('[SSE] creating EventSource');
|
|
32
|
+
|
|
33
|
+
const es = new EventSource(this.url, {
|
|
34
|
+
withCredentials: !!this.opts.withCredentials,
|
|
35
|
+
});
|
|
36
|
+
this.es = es;
|
|
37
|
+
|
|
38
|
+
es.onopen = () => {
|
|
39
|
+
this.setState('connected');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
es.onmessage = (ev) => {
|
|
43
|
+
this.log('[SSE] event', ev);
|
|
44
|
+
this.log('[SSE] message', ev.data);
|
|
45
|
+
this.eventSub.next({type: 'message', data: ev.data, lastEventId: ev.lastEventId || undefined});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
es.onerror = () => {
|
|
49
|
+
// Le navigateur va retenter. On reste en "connecting".
|
|
50
|
+
this.log('[SSE] error → retrying (browser)');
|
|
51
|
+
this.setState('connecting');
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
close(): void {
|
|
56
|
+
if (this.es) {
|
|
57
|
+
this.es.close();
|
|
58
|
+
this.es = undefined;
|
|
59
|
+
this.log('[SSE] closed');
|
|
60
|
+
}
|
|
61
|
+
this.setState('closed');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ──────────────── internals ────────────────
|
|
65
|
+
|
|
66
|
+
private setState(state: RealtimeStatus): void {
|
|
67
|
+
this.log('[SSE] state', state);
|
|
68
|
+
this.statusSub.next(state);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private log(...args: unknown[]): void {
|
|
72
|
+
if (this.debug) console.log(...args);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class MercureUrlBuilder {
|
|
2
|
+
constructor(private readonly apiBase: string) {
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
build(hubUrl: string, iris: 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 iri of iris) {
|
|
13
|
+
url.searchParams.append('topic', this.apiBase + iri);
|
|
14
|
+
}
|
|
15
|
+
return url.toString();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {DOCUMENT, Inject, Injectable, OnDestroy, PLATFORM_ID} from '@angular/core';
|
|
2
|
+
import {BehaviorSubject, defer, fromEvent, Observable, of, Subject} from 'rxjs';
|
|
3
|
+
import {auditTime, concatMap, filter, finalize, map, share, takeUntil} from 'rxjs/operators';
|
|
4
|
+
import {API_BASE_URL, MERCURE_CONFIG, MERCURE_HUB_URL} from '../../tokens';
|
|
5
|
+
import {RealtimeEvent, RealtimePort, RealtimeStatus} from '../../ports/realtime.port';
|
|
6
|
+
import {EventSourceWrapper} from './eventsource-wrapper';
|
|
7
|
+
import {Item} from "../../ports/resource-repository.port";
|
|
8
|
+
import {isPlatformBrowser} from '@angular/common';
|
|
9
|
+
import {MercureUrlBuilder} from './mercure-url.builder';
|
|
10
|
+
import {RefCountTopicRegistry} from './ref-count-topic.registry';
|
|
11
|
+
import {CredentialsPolicy} from '../credentials.policy';
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@Injectable({providedIn: 'root'})
|
|
15
|
+
export class MercureRealtimeAdapter implements RealtimePort, OnDestroy {
|
|
16
|
+
private lastEventId?: string;
|
|
17
|
+
private es?: EventSourceWrapper;
|
|
18
|
+
private currentKey?: string;
|
|
19
|
+
|
|
20
|
+
private readonly topicsRegistry = new RefCountTopicRegistry();
|
|
21
|
+
private readonly urlBuilder: MercureUrlBuilder;
|
|
22
|
+
private readonly credentialsPolicy: CredentialsPolicy;
|
|
23
|
+
|
|
24
|
+
private readonly destroy$ = new Subject<void>();
|
|
25
|
+
private connectionStop$ = new Subject<void>();
|
|
26
|
+
private readonly rebuild$ = new Subject<void>();
|
|
27
|
+
private shuttingDown = false;
|
|
28
|
+
|
|
29
|
+
private readonly _status$ = new BehaviorSubject<RealtimeStatus>('closed');
|
|
30
|
+
private readonly incoming$ = new Subject<{ type: string; data: string }>();
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
@Inject(API_BASE_URL) private readonly apiBase: string,
|
|
34
|
+
@Inject(MERCURE_CONFIG) private readonly init: string,
|
|
35
|
+
@Inject(MERCURE_HUB_URL) private readonly hubUrl: string,
|
|
36
|
+
@Inject(DOCUMENT) private readonly doc: Document,
|
|
37
|
+
@Inject(PLATFORM_ID) private readonly platformId: object,
|
|
38
|
+
) {
|
|
39
|
+
this.urlBuilder = new MercureUrlBuilder(apiBase);
|
|
40
|
+
this.credentialsPolicy = new CredentialsPolicy(init);
|
|
41
|
+
|
|
42
|
+
this.rebuild$
|
|
43
|
+
.pipe(
|
|
44
|
+
auditTime(10),
|
|
45
|
+
concatMap(() => this.rebuildOnce$()),
|
|
46
|
+
)
|
|
47
|
+
.subscribe();
|
|
48
|
+
|
|
49
|
+
if (isPlatformBrowser(this.platformId)) {
|
|
50
|
+
fromEvent<PageTransitionEvent>(window, 'pagehide')
|
|
51
|
+
.pipe(takeUntil(this.destroy$))
|
|
52
|
+
.subscribe(() => this.shutdownBeforeExit());
|
|
53
|
+
|
|
54
|
+
fromEvent<BeforeUnloadEvent>(window, 'beforeunload')
|
|
55
|
+
.pipe(takeUntil(this.destroy$))
|
|
56
|
+
.subscribe(() => this.shutdownBeforeExit());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ──────────────── API publique ────────────────
|
|
61
|
+
|
|
62
|
+
status$(): Observable<RealtimeStatus> {
|
|
63
|
+
return this._status$.asObservable();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
subscribe$<T>(iris: string[], _filter?: { field?: string }): Observable<RealtimeEvent<T>> {
|
|
68
|
+
if (iris.length === 0) {
|
|
69
|
+
return new Observable<RealtimeEvent<T>>((sub) => sub.complete());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.topicsRegistry.addAll(iris);
|
|
73
|
+
this.scheduleRebuild();
|
|
74
|
+
|
|
75
|
+
const filterSet = new Set(iris);
|
|
76
|
+
const fieldPath = _filter?.field;
|
|
77
|
+
|
|
78
|
+
return this.incoming$.pipe(
|
|
79
|
+
map((evt) => this.safeParse(evt.data)),
|
|
80
|
+
|
|
81
|
+
filter((raw: any) => {
|
|
82
|
+
if (fieldPath) {
|
|
83
|
+
const relIri = this.extractRelationIris(raw, fieldPath);
|
|
84
|
+
return Array.from(filterSet).some((iri) => relIri === iri || relIri?.startsWith(`${iri}/`));
|
|
85
|
+
}
|
|
86
|
+
const id = raw?.['@id'];
|
|
87
|
+
return typeof id === 'string' && Array.from(filterSet).some((iri) => {
|
|
88
|
+
const normalized = iri.endsWith('/') ? iri.slice(0, -1) : iri;
|
|
89
|
+
return id === normalized || id.startsWith(`${normalized}/`);
|
|
90
|
+
});
|
|
91
|
+
}),
|
|
92
|
+
map((payload) => ({iri: payload['@id'] as string, data: payload as T})),
|
|
93
|
+
finalize(() => {
|
|
94
|
+
this.topicsRegistry.removeAll(iris);
|
|
95
|
+
this.scheduleRebuild();
|
|
96
|
+
}),
|
|
97
|
+
share()
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
unsubscribe(iris: string[]): void {
|
|
102
|
+
if (!iris || iris.length === 0) return;
|
|
103
|
+
this.topicsRegistry.removeAll(iris);
|
|
104
|
+
this.scheduleRebuild();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public shutdownBeforeExit(): void {
|
|
108
|
+
if (this.shuttingDown) return;
|
|
109
|
+
this.shuttingDown = true;
|
|
110
|
+
this.teardownConnection();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ngOnDestroy(): void {
|
|
114
|
+
this.teardownConnection();
|
|
115
|
+
this._status$.complete();
|
|
116
|
+
this.incoming$.complete();
|
|
117
|
+
this.destroy$.next();
|
|
118
|
+
this.destroy$.complete();
|
|
119
|
+
this.rebuild$.complete();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ───────────────── PRIVATE ─────────────────
|
|
123
|
+
|
|
124
|
+
private scheduleRebuild(): void {
|
|
125
|
+
if (this.shuttingDown) return;
|
|
126
|
+
this.rebuild$.next();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private rebuildOnce$() {
|
|
130
|
+
return defer(() => {
|
|
131
|
+
if (this.shuttingDown) return of(void 0);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const hasTopics = this.topicsRegistry.hasAny();
|
|
135
|
+
const key = hasTopics ? this.topicsRegistry.computeKey(this.hubUrl, this.credentialsPolicy.withCredentials()) : undefined;
|
|
136
|
+
|
|
137
|
+
if (!hasTopics) {
|
|
138
|
+
if (this.es) this.teardownConnection();
|
|
139
|
+
this.currentKey = undefined;
|
|
140
|
+
this._status$.next('closed');
|
|
141
|
+
return of(void 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (key && key === this.currentKey) {
|
|
145
|
+
return of(void 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.teardownConnection();
|
|
149
|
+
|
|
150
|
+
const url = this.urlBuilder.build(this.hubUrl, this.topicsRegistry.snapshot(), this.lastEventId);
|
|
151
|
+
this.es = new EventSourceWrapper(url, {withCredentials: this.credentialsPolicy.withCredentials()});
|
|
152
|
+
|
|
153
|
+
this.connectionStop$ = new Subject<void>();
|
|
154
|
+
|
|
155
|
+
this.es.status$
|
|
156
|
+
.pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
|
|
157
|
+
.subscribe((st) => this.updateGlobalStatus(st));
|
|
158
|
+
|
|
159
|
+
this.es.events$
|
|
160
|
+
.pipe(takeUntil(this.connectionStop$), takeUntil(this.destroy$))
|
|
161
|
+
.subscribe((e) => {
|
|
162
|
+
this.lastEventId = e.lastEventId ?? this.lastEventId;
|
|
163
|
+
this.incoming$.next(e);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this._status$.next('connecting');
|
|
167
|
+
this.es.open();
|
|
168
|
+
this.currentKey = key;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error('[Mercure] rebuild failed:', err);
|
|
171
|
+
this.currentKey = undefined;
|
|
172
|
+
this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return of(void 0);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private teardownConnection(): void {
|
|
180
|
+
this.es?.close();
|
|
181
|
+
this.es = undefined;
|
|
182
|
+
this.connectionStop$.next();
|
|
183
|
+
this.connectionStop$.complete();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private updateGlobalStatus(sse: RealtimeStatus): void {
|
|
187
|
+
if (sse === 'connected') {
|
|
188
|
+
this._status$.next('connected');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (sse === 'connecting') {
|
|
192
|
+
this._status$.next('connecting');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this._status$.next(this.topicsRegistry.hasAny() ? 'connecting' : 'closed');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private safeParse(raw: string): Item {
|
|
199
|
+
return JSON.parse(raw) as Item;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private extractRelationIris(raw: any, path: string): string {
|
|
203
|
+
return raw?.[path];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class RefCountTopicRegistry {
|
|
2
|
+
private readonly iris = new Set<string>();
|
|
3
|
+
private readonly refCounts = new Map<string, number>();
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
addAll(iris: Iterable<string>): void {
|
|
7
|
+
for (const iri of iris) {
|
|
8
|
+
const next = (this.refCounts.get(iri) ?? 0) + 1;
|
|
9
|
+
this.refCounts.set(iri, next);
|
|
10
|
+
this.iris.add(iri);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
removeAll(iris: Iterable<string>): void {
|
|
16
|
+
for (const iri of iris) {
|
|
17
|
+
const next = (this.refCounts.get(iri) ?? 0) - 1;
|
|
18
|
+
if (next <= 0) {
|
|
19
|
+
this.refCounts.delete(iri);
|
|
20
|
+
this.iris.delete(iri);
|
|
21
|
+
} else {
|
|
22
|
+
this.refCounts.set(iri, next);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
hasAny(): boolean {
|
|
29
|
+
return this.iris.size > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
snapshot(): ReadonlySet<string> {
|
|
34
|
+
return new Set(this.iris);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
computeKey(hubUrl: string, credentialsOn: boolean): string {
|
|
40
|
+
const topicsSorted = Array.from(this.iris).sort().join('|');
|
|
41
|
+
return `${hubUrl}::${credentialsOn ? '1' : '0'}::${topicsSorted}`;
|
|
42
|
+
}
|
|
43
|
+
}
|