@obsidiane/meridiane 0.0.6 → 0.0.10

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 (47) hide show
  1. package/README.md +19 -31
  2. package/cli.js +64 -44
  3. package/package.json +3 -7
  4. package/templates/_lib_template/package.json +2 -1
  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/models/index.ts +0 -1
  26. package/templates/_lib_template/src/public-api.ts +3 -2
  27. package/tools/build.js +104 -0
  28. package/tools/core/generate.js +152 -0
  29. package/tools/core/json.js +71 -0
  30. package/tools/core/lib-name.js +15 -0
  31. package/tools/core/logger.js +51 -0
  32. package/tools/core/paths.js +17 -0
  33. package/tools/core/spec.js +59 -0
  34. package/tools/core/standalone.js +117 -0
  35. package/tools/dev.js +218 -0
  36. package/tools/generator/models/handlebars.js +8 -2
  37. package/tools/generator/models/naming.js +21 -15
  38. package/tools/generator/models/openapi-to-models.js +75 -38
  39. package/tools/generator/models/schema-utils.js +84 -66
  40. package/tools/generator/models/templates/model.hbs +3 -3
  41. package/tools/generator/models/utils.js +1 -2
  42. package/models.config.example.js +0 -24
  43. package/templates/_lib_template/src/lib/bridge/credentials.policy.ts +0 -9
  44. package/templates/_lib_template/src/lib/utils/utils.ts +0 -21
  45. package/templates/_lib_template/tsconfig.spec.json +0 -14
  46. package/tools/generate-lib.js +0 -136
  47. package/tools/generate-models.js +0 -121
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,44 +19,30 @@ npm i -g @obsidiane/meridiane
17
19
  ## Usage
18
20
 
19
21
  ```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.
22
+ # Dev: génère le bridge (+ models par défaut)
23
+ # (build standalone + install local dans node_modules)
24
+ npx meridiane dev <packageName> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
28
25
 
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
26
+ # CI/CD: génère + build Angular + npm pack (artefact prêt à publier)
27
+ npx meridiane build <packageName> --version <semver> --spec <url|file> [--preset[=native|all]] [--include <substr>]... [--exclude <substr>]... [--no-models] [--debug]
38
28
  ```
39
29
 
40
- Si votre workspace ne contient pas `ng-packagr` (souvent le cas dans une app Angular “pure”), installez-le :
30
+ Options :
31
+ - `--debug` : active des logs supplémentaires (CLI).
41
32
 
42
- ```bash
43
- npm i -D ng-packagr
44
- ```
33
+ Bonnes pratiques :
34
+ - utiliser `--preset=native` si vous voulez des modèles “entity-like” (sans variantes `.jsonld/.jsonMergePatch`)
35
+ - utiliser `--no-models` si vous voulez uniquement le runtime (pas besoin de `--spec`)
36
+ - laisser le registry à la CI (`.npmrc`, variables d’environnement, `npm publish --registry …`)
45
37
 
46
- ## Configuration (models.config.js)
38
+ Note (repo Meridiane) :
39
+ - dans le repo Meridiane, `meridiane dev` peut être exécutée sans `packageName` et se préconfigure pour `apps/sandbox` (`@obsidiane/bridge-sandbox`).
47
40
 
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) :
41
+ ## Exemple CI (générer + build + publish)
51
42
 
52
43
  ```bash
53
- cp node_modules/@obsidiane/meridiane/models.config.example.js ./models.config.js
44
+ npx -y @obsidiane/meridiane@0.1.0 build @acme/backend-bridge --version 0.1.0 --spec https://staging.example/api/docs.json --preset=native
45
+ npm publish dist/backend-bridge
54
46
  ```
55
47
 
56
- Sans installation préalable :
57
-
58
- ```bash
59
- npx -y @obsidiane/meridiane lib <lib-name> <npm-package-name> [version] [url-registry]
60
- ```
48
+ Note : Meridiane installe le toolchain (`ng-packagr`, `@angular/*`, …) dans `dist/.meridiane-workspace` si nécessaire.
package/cli.js CHANGED
@@ -1,50 +1,70 @@
1
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
- }
2
+ import process from 'node:process';
3
+ import { Command } from 'commander';
4
+
5
+ import { runDev } from './tools/dev.js';
6
+ import { runBuild } from './tools/build.js';
7
+ import { createLogger } from './tools/core/logger.js';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('meridiane')
13
+ .description('Generate an Angular bridge + models from an API Platform OpenAPI spec.')
14
+ .showHelpAfterError()
15
+ .showSuggestionAfterError();
19
16
 
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]
17
+ function presetParser(value, previous) {
18
+ if (value === undefined && previous === undefined) return true; // `--preset` (no value) => native
19
+ return value ?? previous;
20
+ }
25
21
 
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
- `);
22
+ function collect(value, previous = []) {
23
+ return [...previous, value];
30
24
  }
31
25
 
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);
26
+ function commonOptions(cmd) {
27
+ return cmd
28
+ .option('--spec <urlOrFile>', 'OpenAPI spec source (URL or local JSON file)')
29
+ .option('--preset [mode]', 'Preset: native|all (default: all; `--preset` alone => native)', presetParser)
30
+ .option('--include <substr>', 'Include schema names containing substring (repeatable, supports commas)', collect, [])
31
+ .option('--exclude <substr>', 'Exclude schema names containing substring (repeatable, supports commas)', collect, [])
32
+ .option('--no-models', 'Skip models generation (then --spec is not required)')
33
+ .option('--debug', 'Enable debug logs');
50
34
  }
35
+
36
+ commonOptions(
37
+ program
38
+ .command('dev')
39
+ .argument(
40
+ '[packageName]',
41
+ 'NPM package name of the generated bridge (e.g. @acme/backend-bridge). Optional in this repo (defaults to @obsidiane/bridge-sandbox).'
42
+ )
43
+ .action(async (packageName, opts) => {
44
+ const log = createLogger({ debug: !!opts?.debug });
45
+ try {
46
+ await runDev(packageName, opts);
47
+ } catch (err) {
48
+ log.error(err);
49
+ process.exit(1);
50
+ }
51
+ })
52
+ );
53
+
54
+ commonOptions(
55
+ program
56
+ .command('build')
57
+ .argument('<packageName>', 'NPM package name of the generated bridge (e.g. @acme/backend-bridge)')
58
+ .requiredOption('--version <semver>', 'Version to write in the generated bridge package.json (CI/CD)')
59
+ .action(async (packageName, opts) => {
60
+ const log = createLogger({ debug: !!opts?.debug });
61
+ try {
62
+ await runBuild(packageName, opts);
63
+ } catch (err) {
64
+ log.error(err);
65
+ process.exit(1);
66
+ }
67
+ })
68
+ );
69
+
70
+ program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsidiane/meridiane",
3
- "version": "0.0.6",
3
+ "version": "0.0.10",
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",
@@ -14,24 +14,20 @@
14
14
  "pack:check": "npm_config_cache=./.npm-cache npm pack --dry-run"
15
15
  },
16
16
  "bin": {
17
- "meridiane": "cli.js",
18
- "meridiane-generate-lib": "tools/generate-lib.js",
19
- "meridiane-generate-models": "tools/generate-models.js"
17
+ "meridiane": "cli.js"
20
18
  },
21
19
  "files": [
22
20
  "cli.js",
23
21
  "tools/**",
24
22
  "templates/**",
25
- "models.config.example.js",
26
23
  "README.md"
27
24
  ],
28
25
  "engines": {
29
26
  "node": ">=18"
30
27
  },
31
28
  "dependencies": {
32
- "fs-extra": "^11.3.1",
29
+ "commander": "^14.0.2",
33
30
  "handlebars": "^4.7.8",
34
- "openapi-typescript-codegen": "^0.29.0",
35
31
  "tslib": "^2.3.0"
36
32
  }
37
33
  }
@@ -3,7 +3,8 @@
3
3
  "version": "0.0.1",
4
4
  "peerDependencies": {
5
5
  "@angular/common": "^20.1.0",
6
- "@angular/core": "^20.1.0"
6
+ "@angular/core": "^20.1.0",
7
+ "rxjs": "^7.8.0"
7
8
  },
8
9
  "dependencies": {
9
10
  "tslib": "^2.8.1"
@@ -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
  }