@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.
Files changed (37) hide show
  1. package/README.md +42 -2
  2. package/cli.js +27 -1
  3. package/models.config.example.js +15 -0
  4. package/package.json +1 -3
  5. package/templates/_lib_template/src/lib/bridge/rest/api-platform.adapter.ts +48 -47
  6. package/templates/_lib_template/src/lib/bridge/rest/query-builder.ts +29 -29
  7. package/templates/_lib_template/src/lib/bridge/sse/eventsource-wrapper.ts +50 -54
  8. package/templates/_lib_template/src/lib/bridge/sse/mercure-topic.mapper.ts +48 -0
  9. package/templates/_lib_template/src/lib/bridge/sse/mercure-url.builder.ts +7 -7
  10. package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +104 -49
  11. package/templates/_lib_template/src/lib/bridge/sse/ref-count-topic.registry.ts +23 -21
  12. package/templates/_lib_template/src/lib/bridge.types.ts +33 -0
  13. package/templates/_lib_template/src/lib/facades/bridge.facade.ts +116 -0
  14. package/templates/_lib_template/src/lib/facades/facade.factory.ts +21 -19
  15. package/templates/_lib_template/src/lib/facades/facade.interface.ts +13 -18
  16. package/templates/_lib_template/src/lib/facades/resource.facade.ts +42 -56
  17. package/templates/_lib_template/src/lib/interceptors/bridge-debug.interceptor.ts +32 -0
  18. package/templates/_lib_template/src/lib/interceptors/bridge-defaults.interceptor.ts +53 -0
  19. package/templates/_lib_template/src/lib/interceptors/content-type.interceptor.ts +36 -37
  20. package/templates/_lib_template/src/lib/ports/realtime.port.ts +5 -3
  21. package/templates/_lib_template/src/lib/ports/resource-repository.port.ts +14 -15
  22. package/templates/_lib_template/src/lib/provide-bridge.ts +122 -19
  23. package/templates/_lib_template/src/lib/tokens.ts +14 -1
  24. package/templates/_lib_template/src/lib/utils/url.ts +15 -0
  25. package/templates/_lib_template/src/public-api.ts +3 -2
  26. package/tools/dev-bridge.js +138 -0
  27. package/tools/generate-lib.js +60 -45
  28. package/tools/generate-models.js +73 -19
  29. package/tools/generator/models/openapi-to-models.js +13 -2
  30. package/tools/generator/models/schema-utils.js +59 -0
  31. package/tools/generator/models/utils.js +8 -2
  32. package/tools/init.js +148 -0
  33. package/tools/sandbox-bridge.js +77 -0
  34. package/tools/utils/cli-args.js +41 -0
  35. package/tools/utils/dotenv.js +55 -0
  36. package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
  37. 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] [--no-index]
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 [, , cmd, ...rest] = process.argv;
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:
@@ -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.6",
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
- CreateCommand,
6
+ HttpCallOptions,
7
7
  HttpRequestConfig,
8
- Iri, Item,
9
- Query,
8
+ Iri,
9
+ IriRequired,
10
+ Item,
11
+ AnyQuery,
10
12
  ResourceRepository,
11
- UpdateCommand
12
13
  } from '../../ports/resource-repository.port';
13
- import {CredentialsPolicy} from '../credentials.policy';
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 init: string,
21
+ private readonly withCredentialsDefault: boolean,
24
22
  ) {
25
- this.credentialsPolicy = new CredentialsPolicy(init);
26
23
  }
27
24
 
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()
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: Iri): Observable<T> {
36
- return this.http.get<T>(`${this.apiBase}${iri}`, {
37
- withCredentials: this.credentialsPolicy.withCredentials()
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
- create$(cmd: CreateCommand<T>): Observable<T> {
42
- return this.http.post<T>(`${this.apiBase}${this.resourcePath}`, cmd.payload, {
43
- withCredentials: this.credentialsPolicy.withCredentials()
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
- update$(cmd: UpdateCommand<T>): Observable<T> {
48
- return this.http.patch<T>(`${this.apiBase}${cmd.iri}`, cmd.changes, {
49
- withCredentials: this.credentialsPolicy.withCredentials()
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
- delete$(iri: Iri): Observable<void> {
54
- return this.http.delete<void>(`${this.apiBase}${iri}`, {
55
- withCredentials: this.credentialsPolicy.withCredentials()
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
- const computedResponseType = responseType ?? mergedOptions.responseType ?? 'json';
75
- mergedOptions.responseType = computedResponseType as any;
85
+ mergedOptions['responseType'] = (responseType ?? (mergedOptions['responseType'] as any) ?? 'json') as any;
76
86
 
77
- const credentials = withCredentials ?? mergedOptions.withCredentials ?? this.credentialsPolicy.withCredentials();
78
- mergedOptions.withCredentials = credentials;
87
+ mergedOptions['withCredentials'] = withCredentials ?? (mergedOptions['withCredentials'] as any) ?? this.withCredentialsDefault;
79
88
 
80
- if (headers) mergedOptions.headers = headers;
81
- if (query) mergedOptions.params = toHttpParams(query as any);
82
- if (body !== undefined) mergedOptions.body = body;
89
+ if (headers) mergedOptions['headers'] = headers;
90
+ if (query) mergedOptions['params'] = toHttpParams(query);
91
+ if (body !== undefined) mergedOptions['body'] = body;
83
92
 
84
- mergedOptions.observe = 'body';
85
- return this.http.request<R>(method, targetUrl, mergedOptions as { observe: 'body' });
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.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);
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
- if (!q) return new HttpParams();
6
- if (q instanceof HttpParams) return q;
5
+ if (!q) return new HttpParams();
6
+ if (q instanceof HttpParams) return q;
7
7
 
8
- const fromObject: Record<string, string | string[]> = {};
8
+ const fromObject: Record<string, string | string[]> = {};
9
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
- }
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
- if (q.filters) {
22
- consumed.add('filters');
23
- for (const [k, v] of Object.entries(q.filters)) {
24
- assign(fromObject, k, v);
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
- for (const [k, v] of Object.entries(q as Record<string, QueryParamValue>)) {
29
- if (consumed.has(k)) continue;
30
- assign(fromObject, k, v);
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
- return new HttpParams({fromObject});
33
+ return new HttpParams({fromObject});
34
34
  }
35
35
 
36
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
- }
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
- private es?: EventSource;
6
+ private es?: EventSource;
6
7
 
7
- private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
8
- private readonly eventSub = new Subject<SseEvent>();
8
+ private readonly statusSub = new ReplaySubject<RealtimeStatus>(1);
9
+ private readonly eventSub = new Subject<SseEvent>();
9
10
 
10
- readonly status$ = this.statusSub.asObservable();
11
- readonly events$ = this.eventSub.asObservable();
11
+ readonly status$ = this.statusSub.asObservable();
12
+ readonly events$ = this.eventSub.asObservable();
12
13
 
13
- /** Activer pour conversations local */
14
- private readonly debug = false;
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
- constructor(
17
- private readonly url: string,
18
- private readonly opts: SseOptions = {}
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
- open(): void {
25
- if (this.es) {
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
- this.setState('connecting');
31
- this.log('[SSE] creating EventSource');
32
+ const es = new EventSource(this.url, {
33
+ withCredentials: !!this.opts.withCredentials,
34
+ });
35
+ this.es = es;
32
36
 
33
- const es = new EventSource(this.url, {
34
- withCredentials: !!this.opts.withCredentials,
35
- });
36
- this.es = es;
37
+ es.onopen = () => {
38
+ this.setState('connected');
39
+ };
37
40
 
38
- es.onopen = () => {
39
- this.setState('connected');
40
- };
41
+ es.onmessage = (ev) => {
42
+ this.eventSub.next({type: 'message', data: ev.data, lastEventId: ev.lastEventId || undefined});
43
+ };
41
44
 
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
- };
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
- es.onerror = () => {
49
- // Le navigateur va retenter. On reste en "connecting".
50
- this.log('[SSE] error → retrying (browser)');
51
- this.setState('connecting');
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
- 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 ────────────────
61
+ // ──────────────── internals ────────────────
65
62
 
66
- private setState(state: RealtimeStatus): void {
67
- this.log('[SSE] state', state);
68
- this.statusSub.next(state);
69
- }
63
+ private setState(state: RealtimeStatus): void {
64
+ this.statusSub.next(state);
65
+ }
70
66
 
71
- private log(...args: unknown[]): void {
72
- if (this.debug) console.log(...args);
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
- constructor(private readonly apiBase: string) {
3
- }
4
-
5
-
6
- build(hubUrl: string, iris: ReadonlySet<string>, lastEventId?: string): 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 iri of iris) {
13
- url.searchParams.append('topic', this.apiBase + iri);
12
+ for (const topic of topics) {
13
+ url.searchParams.append('topic', topic);
14
14
  }
15
15
  return url.toString();
16
16
  }