@obsidiane/meridiane 0.0.6 → 0.0.7
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 +42 -2
- package/cli.js +27 -1
- package/models.config.example.js +15 -0
- package/package.json +1 -3
- package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +48 -47
- package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +29 -29
- package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +50 -54
- package/templates/_lib_template/src/lib/bridge/sse/mercure-topic.mapper.ts +48 -0
- package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +7 -7
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +104 -49
- package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +23 -21
- package/templates/_lib_template/src/lib/bridge.types.ts +33 -0
- package/templates/_lib_template/src/lib/facades/bridge.facade.ts +116 -0
- package/templates/_lib_template/src/lib/facades/facade.factory.ts +21 -19
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +13 -18
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +42 -56
- package/templates/_lib_template/src/lib/interceptors/bridge-debug.interceptor.ts +32 -0
- package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +53 -0
- package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +36 -37
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +5 -3
- package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +14 -15
- package/templates/_lib_template/src/lib/provide-bridge.ts +122 -19
- package/templates/_lib_template/src/lib/tokens.ts +14 -1
- package/templates/_lib_template/src/lib/utils/url.ts +15 -0
- package/templates/_lib_template/src/public-api.ts +3 -2
- package/tools/dev-bridge.js +138 -0
- package/tools/generate-lib.js +60 -45
- package/tools/generate-models.js +73 -19
- package/tools/generator/models/openapi-to-models.js +13 -2
- package/tools/generator/models/schema-utils.js +59 -0
- package/tools/generator/models/utils.js +8 -2
- package/tools/init.js +148 -0
- package/tools/sandbox-bridge.js +77 -0
- package/tools/utils/cli-args.js +41 -0
- package/tools/utils/dotenv.js +55 -0
- package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
- package/templates/_lib_template/src/lib/utils/utils.ts +0 -21
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
Documentation détaillée (repo) : `docs/index.md`.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -17,15 +19,32 @@ npm i -g @obsidiane/meridiane
|
|
|
17
19
|
## Usage
|
|
18
20
|
|
|
19
21
|
```bash
|
|
22
|
+
# Générer des fichiers d'init (config + snippets)
|
|
23
|
+
npx meridiane init
|
|
24
|
+
|
|
20
25
|
# Générer une librairie Angular (dans projects/<lib-name>)
|
|
21
26
|
npx meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
22
27
|
|
|
23
28
|
# 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] [--
|
|
29
|
+
npx meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [--out=<dir>] [--item-import=../lib/ports/resource-repository.port] [--required-mode=all-optional|spec] [--preset=all|native] [--include=<substr>] [--exclude=<substr>] [--index=1|0]
|
|
30
|
+
|
|
31
|
+
# Dev: regen lib + models depuis un backend (localhost par défaut)
|
|
32
|
+
npx meridiane dev-bridge <lib-name> <npm-package-name> [version]
|
|
33
|
+
|
|
34
|
+
# Dev (repo meridiane): met à jour le bridge du sandbox
|
|
35
|
+
npx meridiane sandbox-bridge
|
|
25
36
|
```
|
|
26
37
|
|
|
38
|
+
Options :
|
|
39
|
+
- `--debug` : active des logs supplémentaires (CLI).
|
|
40
|
+
|
|
27
41
|
`url-registry` est optionnel. Recommandé : gérer le registry via `.npmrc` / variables CI plutôt que de le passer au générateur.
|
|
28
42
|
|
|
43
|
+
Bonnes pratiques :
|
|
44
|
+
- exécuter `meridiane init` une fois au début du repo du bridge (pour `models.config.js`, `.env.example`, snippets Angular)
|
|
45
|
+
- committer `models.config.js` et `.env.example`, ne pas committer `.env`
|
|
46
|
+
- générer les modèles dans `projects/<lib-name>/src/models` pour publier les types avec le package
|
|
47
|
+
|
|
29
48
|
## Exemple CI (générer + build + publish)
|
|
30
49
|
|
|
31
50
|
```bash
|
|
@@ -45,7 +64,7 @@ npm i -D ng-packagr
|
|
|
45
64
|
|
|
46
65
|
## Configuration (models.config.js)
|
|
47
66
|
|
|
48
|
-
Le générateur de modèles charge automatiquement un fichier `models.config.js` s’il existe dans le répertoire courant (CWD).
|
|
67
|
+
Le générateur de modèles charge automatiquement un fichier `models.config.js` s’il existe dans le répertoire courant (CWD) ou dans un dossier parent.
|
|
49
68
|
|
|
50
69
|
Copie rapide (après installation) :
|
|
51
70
|
|
|
@@ -53,6 +72,27 @@ Copie rapide (après installation) :
|
|
|
53
72
|
cp node_modules/@obsidiane/meridiane/models.config.example.js ./models.config.js
|
|
54
73
|
```
|
|
55
74
|
|
|
75
|
+
Exécuter `meridiane init` est généralement plus simple (ça pose aussi `.env.example` et un snippet Angular).
|
|
76
|
+
|
|
77
|
+
## Configuration via `.env`
|
|
78
|
+
|
|
79
|
+
Le CLI charge automatiquement un fichier `.env` (CWD ou dossier parent) avant d’exécuter `lib/models`.
|
|
80
|
+
|
|
81
|
+
Variables utiles :
|
|
82
|
+
- `MERIDIANE_DEBUG=1`
|
|
83
|
+
- `MERIDIANE_LIB_VERSION=0.1.0`
|
|
84
|
+
- `MERIDIANE_NPM_REGISTRY_URL=https://…`
|
|
85
|
+
- `MERIDIANE_MODELS_OUT=projects/<lib>/src/models`
|
|
86
|
+
- `MERIDIANE_MODELS_REQUIRED_MODE=spec`
|
|
87
|
+
- `MERIDIANE_MODELS_ITEM_IMPORT=../lib/ports/resource-repository.port`
|
|
88
|
+
- `MERIDIANE_MODELS_INDEX=1`
|
|
89
|
+
|
|
90
|
+
Ordre de priorité :
|
|
91
|
+
1) flags CLI
|
|
92
|
+
2) `models.config.js`
|
|
93
|
+
3) `.env`
|
|
94
|
+
4) valeurs par défaut
|
|
95
|
+
|
|
56
96
|
Sans installation préalable :
|
|
57
97
|
|
|
58
98
|
```bash
|
package/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ function runTool(scriptName, args) {
|
|
|
10
10
|
const scriptPath = path.join(__dirname, 'tools', scriptName);
|
|
11
11
|
const child = spawn(process.execPath, [scriptPath, ...args], {
|
|
12
12
|
stdio: 'inherit',
|
|
13
|
+
env: process.env,
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
child.on('exit', (code) => {
|
|
@@ -20,24 +21,49 @@ function runTool(scriptName, args) {
|
|
|
20
21
|
function printHelp() {
|
|
21
22
|
console.log(`
|
|
22
23
|
Usage:
|
|
24
|
+
meridiane init [--force]
|
|
23
25
|
meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
|
|
24
26
|
meridiane models <SPEC_OPENAPI_URL_OU_FICHIER_JSON> [options]
|
|
27
|
+
meridiane dev-bridge <lib-name> <npm-package-name> [version] [options]
|
|
28
|
+
meridiane sandbox-bridge [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--debug Active les logs (CLI et runtime)
|
|
25
32
|
|
|
26
33
|
Examples:
|
|
34
|
+
npx -y @obsidiane/meridiane init
|
|
27
35
|
npx -y @obsidiane/meridiane lib backend-bridge @acme/backend-bridge 0.1.0
|
|
28
36
|
npx -y @obsidiane/meridiane models http://localhost:8000/api/docs.json --out=projects/backend-bridge/src/models
|
|
37
|
+
npx -y @obsidiane/meridiane dev-bridge bridge-sandbox @obsidiane/bridge-sandbox 0.1.0
|
|
38
|
+
npx -y @obsidiane/meridiane sandbox-bridge
|
|
29
39
|
`);
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
const
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const debugIndex = argv.indexOf('--debug');
|
|
44
|
+
if (debugIndex >= 0) {
|
|
45
|
+
argv.splice(debugIndex, 1);
|
|
46
|
+
process.env.MERIDIANE_DEBUG = process.env.MERIDIANE_DEBUG || '1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [cmd, ...rest] = argv;
|
|
33
50
|
|
|
34
51
|
switch (cmd) {
|
|
52
|
+
case 'init':
|
|
53
|
+
runTool('init.js', rest);
|
|
54
|
+
break;
|
|
35
55
|
case 'lib':
|
|
36
56
|
runTool('generate-lib.js', rest);
|
|
37
57
|
break;
|
|
38
58
|
case 'models':
|
|
39
59
|
runTool('generate-models.js', rest);
|
|
40
60
|
break;
|
|
61
|
+
case 'dev-bridge':
|
|
62
|
+
runTool('dev-bridge.js', rest);
|
|
63
|
+
break;
|
|
64
|
+
case 'sandbox-bridge':
|
|
65
|
+
runTool('sandbox-bridge.js', rest);
|
|
66
|
+
break;
|
|
41
67
|
case '-h':
|
|
42
68
|
case '--help':
|
|
43
69
|
case undefined:
|
package/models.config.example.js
CHANGED
|
@@ -18,6 +18,21 @@ module.exports = {
|
|
|
18
18
|
// Valeurs possibles: 'jsonld' | 'jsonapi' | 'none'
|
|
19
19
|
preferFlavor: 'jsonld',
|
|
20
20
|
|
|
21
|
+
// Preset de génération:
|
|
22
|
+
// - 'all' (défaut): génère tout ce qui ressemble à un objet
|
|
23
|
+
// - 'native': exclut les schémas "techniques" (Hydra*, jsonMergePatch…)
|
|
24
|
+
preset: 'all',
|
|
25
|
+
|
|
26
|
+
// Générer `index.ts` dans le dossier des models
|
|
27
|
+
writeIndex: true,
|
|
28
|
+
|
|
29
|
+
// Filtres additionnels (sur les noms OpenAPI des schémas, ex: "User-user.read", "Conversation.jsonMergePatch")
|
|
30
|
+
// - includeSchemaNames: si non vide, ne garde que les matchs
|
|
31
|
+
// - excludeSchemaNames: supprime les matchs
|
|
32
|
+
// Règles possibles: RegExp, string (substring), ou (name) => boolean.
|
|
33
|
+
includeSchemaNames: [],
|
|
34
|
+
excludeSchemaNames: [],
|
|
35
|
+
|
|
21
36
|
// Hydra: regex pour les schémas de base à ignorer (merge allOf / filtrage)
|
|
22
37
|
// Peut être une RegExp ou une string passée à `new RegExp(...)`
|
|
23
38
|
hydraBaseRegex: /^Hydra(?:Item|Collection)BaseSchema$/,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsidiane/meridiane",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,9 +29,7 @@
|
|
|
29
29
|
"node": ">=18"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"fs-extra": "^11.3.1",
|
|
33
32
|
"handlebars": "^4.7.8",
|
|
34
|
-
"openapi-typescript-codegen": "^0.29.0",
|
|
35
33
|
"tslib": "^2.3.0"
|
|
36
34
|
}
|
|
37
35
|
}
|
|
@@ -3,60 +3,71 @@ import {Observable} from 'rxjs';
|
|
|
3
3
|
import {toHttpParams} from './query-builder';
|
|
4
4
|
import {
|
|
5
5
|
Collection,
|
|
6
|
-
|
|
6
|
+
HttpCallOptions,
|
|
7
7
|
HttpRequestConfig,
|
|
8
|
-
Iri,
|
|
9
|
-
|
|
8
|
+
Iri,
|
|
9
|
+
IriRequired,
|
|
10
|
+
Item,
|
|
11
|
+
AnyQuery,
|
|
10
12
|
ResourceRepository,
|
|
11
|
-
UpdateCommand
|
|
12
13
|
} from '../../ports/resource-repository.port';
|
|
13
|
-
import {
|
|
14
|
-
|
|
14
|
+
import {resolveUrl} from '../../utils/url';
|
|
15
15
|
|
|
16
16
|
export class ApiPlatformRestRepository<T extends Item> implements ResourceRepository<T> {
|
|
17
|
-
private readonly credentialsPolicy: CredentialsPolicy;
|
|
18
|
-
|
|
19
17
|
constructor(
|
|
20
18
|
private readonly http: HttpClient,
|
|
21
19
|
private readonly apiBase: string,
|
|
22
20
|
private readonly resourcePath: Iri,
|
|
23
|
-
readonly
|
|
21
|
+
private readonly withCredentialsDefault: boolean,
|
|
24
22
|
) {
|
|
25
|
-
this.credentialsPolicy = new CredentialsPolicy(init);
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>> {
|
|
26
|
+
const params = toHttpParams(query);
|
|
27
|
+
return this.http.get<Collection<T>>(this.resolveUrl(this.resourcePath), {
|
|
28
|
+
params,
|
|
29
|
+
headers: opts?.headers,
|
|
30
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
32
31
|
});
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
get$(iri:
|
|
36
|
-
return this.http.get<T>(
|
|
37
|
-
|
|
34
|
+
get$(iri: IriRequired, opts?: HttpCallOptions): Observable<T> {
|
|
35
|
+
return this.http.get<T>(this.resolveUrl(iri), {
|
|
36
|
+
headers: opts?.headers,
|
|
37
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
return this.http.post<T>(
|
|
43
|
-
|
|
41
|
+
post$(payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
42
|
+
return this.http.post<T>(this.resolveUrl(this.resourcePath), payload, {
|
|
43
|
+
headers: opts?.headers,
|
|
44
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
return this.http.patch<T>(
|
|
49
|
-
|
|
48
|
+
patch$(iri: IriRequired, changes: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
49
|
+
return this.http.patch<T>(this.resolveUrl(iri), changes, {
|
|
50
|
+
headers: opts?.headers,
|
|
51
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
return this.http.
|
|
55
|
-
|
|
55
|
+
put$(iri: IriRequired, payload: Partial<T>, opts?: HttpCallOptions): Observable<T> {
|
|
56
|
+
return this.http.put<T>(this.resolveUrl(iri), payload, {
|
|
57
|
+
headers: opts?.headers,
|
|
58
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
delete$(iri: IriRequired, opts?: HttpCallOptions): Observable<void> {
|
|
63
|
+
return this.http.delete<void>(this.resolveUrl(iri), {
|
|
64
|
+
headers: opts?.headers,
|
|
65
|
+
withCredentials: opts?.withCredentials ?? this.withCredentialsDefault,
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R> {
|
|
70
|
+
// Low-level escape hatch for non-standard endpoints (custom controllers, uploads, etc.).
|
|
60
71
|
const {
|
|
61
72
|
method,
|
|
62
73
|
url,
|
|
@@ -65,37 +76,27 @@ export class ApiPlatformRestRepository<T extends Item> implements ResourceReposi
|
|
|
65
76
|
headers,
|
|
66
77
|
responseType,
|
|
67
78
|
withCredentials,
|
|
68
|
-
options = {}
|
|
79
|
+
options = {},
|
|
69
80
|
} = req;
|
|
70
81
|
|
|
71
|
-
const mergedOptions: any = {...options};
|
|
72
82
|
const targetUrl = this.resolveUrl(url ?? this.resourcePath);
|
|
83
|
+
const mergedOptions: Record<string, unknown> = {...options};
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
mergedOptions.responseType = computedResponseType as any;
|
|
85
|
+
mergedOptions['responseType'] = (responseType ?? (mergedOptions['responseType'] as any) ?? 'json') as any;
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
mergedOptions.withCredentials = credentials;
|
|
87
|
+
mergedOptions['withCredentials'] = withCredentials ?? (mergedOptions['withCredentials'] as any) ?? this.withCredentialsDefault;
|
|
79
88
|
|
|
80
|
-
if (headers) mergedOptions
|
|
81
|
-
if (query) mergedOptions
|
|
82
|
-
if (body !== undefined) mergedOptions
|
|
89
|
+
if (headers) mergedOptions['headers'] = headers;
|
|
90
|
+
if (query) mergedOptions['params'] = toHttpParams(query);
|
|
91
|
+
if (body !== undefined) mergedOptions['body'] = body;
|
|
83
92
|
|
|
84
|
-
mergedOptions
|
|
85
|
-
return this.http.request<R>(method, targetUrl, mergedOptions as {
|
|
93
|
+
mergedOptions['observe'] = 'body';
|
|
94
|
+
return this.http.request<R>(method, targetUrl, mergedOptions as {observe: 'body'});
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
private resolveUrl(path?: Iri): string {
|
|
89
98
|
const effectivePath = path ?? this.resourcePath;
|
|
90
|
-
if (!effectivePath) throw new Error('ApiPlatformRestRepository
|
|
91
|
-
|
|
92
|
-
if (effectivePath.startsWith('//')) return effectivePath;
|
|
93
|
-
return joinUrl(this.apiBase, effectivePath);
|
|
99
|
+
if (!effectivePath) throw new Error('ApiPlatformRestRepository: missing url and resourcePath');
|
|
100
|
+
return resolveUrl(this.apiBase, effectivePath);
|
|
94
101
|
}
|
|
95
102
|
}
|
|
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
|
-
}
|
|
@@ -2,42 +2,42 @@ import {HttpParams} from '@angular/common/http';
|
|
|
2
2
|
import {AnyQuery, Query, QueryParamValue} from '../../ports/resource-repository.port';
|
|
3
3
|
|
|
4
4
|
export function toHttpParams(q: AnyQuery | undefined): HttpParams {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
if (!q) return new HttpParams();
|
|
6
|
+
if (q instanceof HttpParams) return q;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const fromObject: Record<string, string | string[]> = {};
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
21
|
+
if (q.filters) {
|
|
22
|
+
consumed.add('filters');
|
|
23
|
+
for (const [k, v] of Object.entries(q.filters)) {
|
|
24
|
+
assign(fromObject, k, v);
|
|
26
25
|
}
|
|
26
|
+
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
32
|
|
|
33
|
-
|
|
33
|
+
return new HttpParams({fromObject});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function assign(target: Record<string, string | string[]>, key: string, value: QueryParamValue | undefined) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
43
|
}
|
|
@@ -1,74 +1,70 @@
|
|
|
1
1
|
import {ReplaySubject, Subject} from 'rxjs';
|
|
2
2
|
import {SseEvent, SseOptions, RealtimeStatus} from '../../ports/realtime.port';
|
|
3
|
+
import {BridgeLogger} from '../../bridge.types';
|
|
3
4
|
|
|
4
5
|
export class EventSourceWrapper {
|
|
5
|
-
|
|
6
|
+
private es?: EventSource;
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
|
|
9
|
+
private readonly eventSub = new Subject<SseEvent>();
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
readonly status$ = this.statusSub.asObservable();
|
|
12
|
+
readonly events$ = this.eventSub.asObservable();
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
private readonly
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly url: string,
|
|
16
|
+
private readonly opts: SseOptions = {},
|
|
17
|
+
private readonly logger?: BridgeLogger,
|
|
18
|
+
) {
|
|
19
|
+
this.setState('closed');
|
|
20
|
+
this.log('[SSE] init', {url, withCredentials: !!opts.withCredentials});
|
|
21
|
+
}
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.setState('closed');
|
|
21
|
-
this.log('[SSE] initialized', url, opts);
|
|
23
|
+
open(): void {
|
|
24
|
+
if (this.es) {
|
|
25
|
+
this.log('[SSE] open() ignored: already open');
|
|
26
|
+
return;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.log('[SSE] open() ignored: already open');
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
+
this.setState('connecting');
|
|
30
|
+
this.log('[SSE] open', {url: this.url});
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const es = new EventSource(this.url, {
|
|
33
|
+
withCredentials: !!this.opts.withCredentials,
|
|
34
|
+
});
|
|
35
|
+
this.es = es;
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.es = es;
|
|
37
|
+
es.onopen = () => {
|
|
38
|
+
this.setState('connected');
|
|
39
|
+
};
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
es.onmessage = (ev) => {
|
|
42
|
+
this.eventSub.next({type: 'message', data: ev.data, lastEventId: ev.lastEventId || undefined});
|
|
43
|
+
};
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
es.onerror = () => {
|
|
46
|
+
// The browser will retry automatically. We stay in "connecting".
|
|
47
|
+
this.log('[SSE] error');
|
|
48
|
+
this.setState('connecting');
|
|
49
|
+
};
|
|
50
|
+
}
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
close(): void {
|
|
53
|
+
if (this.es) {
|
|
54
|
+
this.es.close();
|
|
55
|
+
this.es = undefined;
|
|
56
|
+
this.log('[SSE] closed');
|
|
53
57
|
}
|
|
58
|
+
this.setState('closed');
|
|
59
|
+
}
|
|
54
60
|
|
|
55
|
-
|
|
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 ────────────────
|
|
61
|
+
// ──────────────── internals ────────────────
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
63
|
+
private setState(state: RealtimeStatus): void {
|
|
64
|
+
this.statusSub.next(state);
|
|
65
|
+
}
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
private log(...args: unknown[]): void {
|
|
68
|
+
this.logger?.debug?.(...args);
|
|
69
|
+
}
|
|
74
70
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {MercureTopicMode} from '../../bridge.types';
|
|
2
|
+
|
|
3
|
+
export class MercureTopicMapper {
|
|
4
|
+
private readonly apiBaseUrl: URL;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
apiBase: string,
|
|
8
|
+
private readonly mode: MercureTopicMode,
|
|
9
|
+
) {
|
|
10
|
+
this.apiBaseUrl = new URL(apiBase);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canonical value used for ref-counting and as the "topic" query param value.
|
|
15
|
+
* - mode "url": always absolute, same-origin resolved
|
|
16
|
+
* - mode "iri": same-origin path+query+hash ("/api/..."), otherwise keep as-is
|
|
17
|
+
*/
|
|
18
|
+
toTopic(input: string): string {
|
|
19
|
+
if (this.mode === 'url') return this.toAbsoluteUrl(input);
|
|
20
|
+
return this.toRelativeIriIfSameOrigin(input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Canonical value used to compare incoming payload IRIs with subscribed IRIs.
|
|
25
|
+
* We keep payload matching stable by using same-origin relative IRIs ("/api/...").
|
|
26
|
+
*/
|
|
27
|
+
toPayloadIri(input: string): string {
|
|
28
|
+
return this.toRelativeIriIfSameOrigin(input);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private toAbsoluteUrl(input: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(input, this.apiBaseUrl).toString();
|
|
34
|
+
} catch {
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private toRelativeIriIfSameOrigin(input: string): string {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(input, this.apiBaseUrl);
|
|
42
|
+
if (url.origin !== this.apiBaseUrl.origin) return input;
|
|
43
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
44
|
+
} catch {
|
|
45
|
+
return input;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
export class MercureUrlBuilder {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
build(hubUrl: string,
|
|
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
7
|
const url = new URL(hubUrl);
|
|
8
8
|
if (lastEventId) {
|
|
9
9
|
url.searchParams.set('lastEventID', lastEventId);
|
|
10
10
|
}
|
|
11
11
|
url.searchParams.delete('topic');
|
|
12
|
-
for (const
|
|
13
|
-
url.searchParams.append('topic',
|
|
12
|
+
for (const topic of topics) {
|
|
13
|
+
url.searchParams.append('topic', topic);
|
|
14
14
|
}
|
|
15
15
|
return url.toString();
|
|
16
16
|
}
|