@obsidiane/meridiane 2.0.13 → 3.0.0

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 (58) hide show
  1. package/README.md +30 -68
  2. package/cli.js +1 -10
  3. package/package.json +1 -1
  4. package/src/application/bridge-workflow.js +23 -66
  5. package/src/application/generate-bridge.js +32 -36
  6. package/src/application/run-build.js +8 -30
  7. package/src/application/run-dev.js +8 -63
  8. package/src/application/run-generate.js +8 -47
  9. package/src/domain/bridge-options.js +10 -63
  10. package/src/generators/bridge/write-bridge-files.js +11 -29
  11. package/src/generators/models/interface-schema.js +3 -20
  12. package/src/generators/models/model-selection.js +67 -106
  13. package/src/generators/models/oas-contract.js +1 -108
  14. package/src/generators/models/oas-schema-name.js +1 -19
  15. package/src/generators/models/openapi-to-models.js +51 -42
  16. package/src/generators/models/schema-utils.js +0 -34
  17. package/src/generators/models/type-resolver.js +2 -20
  18. package/src/generators/models/utils.js +0 -11
  19. package/src/generators/models/write-model-files.js +5 -15
  20. package/src/infrastructure/dev-project.js +0 -33
  21. package/src/infrastructure/files.js +15 -0
  22. package/src/infrastructure/local-package-install.js +2 -2
  23. package/src/infrastructure/logger.js +0 -2
  24. package/templates/angular-bridge/README.md +29 -281
  25. package/templates/angular-bridge/package.json +2 -2
  26. package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +10 -19
  27. package/templates/angular-bridge/src/lib/bridge/sse/mercure-hub.ts +449 -0
  28. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.ts +28 -0
  29. package/templates/angular-bridge/src/lib/bridge/sse/resource-topic.ts +83 -0
  30. package/templates/angular-bridge/src/lib/bridge-options.ts +21 -17
  31. package/templates/angular-bridge/src/lib/bridge.types.ts +22 -33
  32. package/templates/angular-bridge/src/lib/facades/bridge.facade.ts +58 -134
  33. package/templates/angular-bridge/src/lib/facades/facade.factory.ts +25 -25
  34. package/templates/angular-bridge/src/lib/facades/facade.interface.ts +4 -9
  35. package/templates/angular-bridge/src/lib/facades/resource.facade.ts +38 -61
  36. package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +8 -12
  37. package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +19 -11
  38. package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +5 -17
  39. package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +28 -24
  40. package/templates/angular-bridge/src/lib/ports/realtime.port.ts +34 -53
  41. package/templates/angular-bridge/src/lib/ports/resource-repository.port.ts +31 -21
  42. package/templates/angular-bridge/src/lib/provide-bridge.ts +14 -38
  43. package/templates/angular-bridge/src/lib/tokens.ts +4 -15
  44. package/templates/angular-bridge/src/lib/utils/request-url.ts +4 -6
  45. package/templates/angular-bridge/src/lib/utils/url.ts +32 -9
  46. package/templates/angular-bridge/src/public-api.ts +0 -1
  47. package/templates/angular-bridge/tsconfig.lib.json +0 -3
  48. package/src/infrastructure/json.js +0 -71
  49. package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +0 -82
  50. package/templates/angular-bridge/src/lib/bridge/sse/eventsource-wrapper.ts +0 -70
  51. package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +0 -255
  52. package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +0 -82
  53. package/templates/angular-bridge/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -48
  54. package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +0 -39
  55. package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +0 -358
  56. package/templates/angular-bridge/src/lib/bridge/sse/ref-count-topic.registry.ts +0 -36
  57. package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +0 -76
  58. package/templates/angular-bridge/src/lib/utils/iri.ts +0 -6
@@ -1,30 +1,18 @@
1
1
  import {HttpInterceptorFn} from '@angular/common/http';
2
2
 
3
- export interface ApiMediaTypes {
4
- accept: string;
5
- post: string;
6
- put: string;
7
- patch: string;
8
- }
9
-
10
- export const DEFAULT_MEDIA_TYPES: ApiMediaTypes = {
11
- accept: 'application/ld+json',
12
- post: 'application/ld+json',
13
- put: 'application/ld+json',
14
- patch: 'application/merge-patch+json',
15
- };
3
+ const ACCEPT = 'application/ld+json';
16
4
 
17
5
  const CONTENT_TYPE_BY_METHOD: Record<string, string> = {
18
- POST: DEFAULT_MEDIA_TYPES.post,
19
- PUT: DEFAULT_MEDIA_TYPES.put,
20
- PATCH: DEFAULT_MEDIA_TYPES.patch,
6
+ POST: 'application/ld+json',
7
+ PUT: 'application/ld+json',
8
+ PATCH: 'application/merge-patch+json',
21
9
  };
22
10
 
23
11
  export const contentTypeInterceptor: HttpInterceptorFn = (req, next) => {
24
12
  let headers = req.headers;
25
13
 
26
14
  if (!headers.has('Accept')) {
27
- headers = headers.set('Accept', DEFAULT_MEDIA_TYPES.accept);
15
+ headers = headers.set('Accept', ACCEPT);
28
16
  }
29
17
 
30
18
  const contentType = CONTENT_TYPE_BY_METHOD[req.method];
@@ -1,32 +1,36 @@
1
1
  import {HttpEvent, HttpHeaders, HttpInterceptorFn, HttpRequest} from '@angular/common/http';
2
+ import {inject, InjectionToken} from '@angular/core';
2
3
  import {finalize, Observable, shareReplay} from 'rxjs';
3
4
 
4
5
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
5
6
 
6
- export function createSingleFlightInterceptor(): HttpInterceptorFn {
7
- const inFlight = new Map<string, Observable<HttpEvent<unknown>>>();
8
-
9
- return (request, next) => {
10
- const method = request.method.toUpperCase();
11
- if (!SAFE_METHODS.has(method) || request.reportProgress) {
12
- return next(request);
13
- }
14
-
15
- const key = computeKey(request);
16
- const existing = inFlight.get(key);
17
- if (existing) {
18
- return existing;
19
- }
20
-
21
- const shared$ = next(request).pipe(
22
- finalize(() => inFlight.delete(key)),
23
- shareReplay({bufferSize: 1, refCount: true})
24
- );
25
-
26
- inFlight.set(key, shared$);
27
- return shared$;
28
- };
29
- }
7
+ /** In-flight requests of one application instance (one per server-side render). */
8
+ const IN_FLIGHT_REQUESTS = new InjectionToken<Map<string, Observable<HttpEvent<unknown>>>>('IN_FLIGHT_REQUESTS', {
9
+ providedIn: 'root',
10
+ factory: () => new Map(),
11
+ });
12
+
13
+ export const singleFlightInterceptor: HttpInterceptorFn = (request, next) => {
14
+ const method = request.method.toUpperCase();
15
+ if (!SAFE_METHODS.has(method) || request.reportProgress) {
16
+ return next(request);
17
+ }
18
+
19
+ const inFlight = inject(IN_FLIGHT_REQUESTS);
20
+ const key = computeKey(request);
21
+ const existing = inFlight.get(key);
22
+ if (existing) {
23
+ return existing;
24
+ }
25
+
26
+ const shared$ = next(request).pipe(
27
+ finalize(() => inFlight.delete(key)),
28
+ shareReplay({bufferSize: 1, refCount: true})
29
+ );
30
+
31
+ inFlight.set(key, shared$);
32
+ return shared$;
33
+ };
30
34
 
31
35
  function computeKey(request: HttpRequest<unknown>): string {
32
36
  return [
@@ -1,71 +1,52 @@
1
+ import {Signal} from '@angular/core';
1
2
  import {Observable} from 'rxjs';
2
- import {Iri} from './resource-repository.port';
3
- import {RealtimeConnectionStatus, RealtimeDiagnostics, WatchConnectionOptions} from '../bridge.types';
4
-
5
- export type RealtimeStatus = RealtimeConnectionStatus;
6
-
7
- export interface SseEvent {
8
- type: string;
9
- data: string;
10
- lastEventId?: string;
3
+ import {RealtimeDiagnostics, RealtimeStatus} from '../bridge.types';
4
+
5
+ /**
6
+ * A Mercure 1.0 subscription, sent to the hub as `match=` (exact) or `match_urlpattern=` (WHATWG URL Pattern).
7
+ */
8
+ export interface MercureSubscription {
9
+ /** Absolute topic, or absolute URL Pattern when `type` is `urlpattern` (e.g. `https://api.example.com/api/books/:id`). */
10
+ match: string;
11
+ type: 'exact' | 'urlpattern';
11
12
  }
12
13
 
13
- export interface SseOptions {
14
- withCredentials?: boolean;
15
- }
14
+ /**
15
+ * Connection carrying a subscription:
16
+ * - `shared`: connections shared by the application, split automatically when too long;
17
+ * - `dedicated`: a connection for this subscription only;
18
+ * - any other value: connections shared by the subscriptions using this name.
19
+ */
20
+ export type RealtimeConnection = 'shared' | 'dedicated' | (string & {});
16
21
 
17
- export interface RealtimeEvent<T> {
18
- iri: string;
19
- /**
20
- * Matched subscription topic (normalized).
21
- *
22
- * - When matching by `@id`, this is typically the same as `iri`.
23
- * - When matching by a relation field (e.g. `conversation`), this is the parent topic IRI.
24
- */
25
- topic?: string;
26
- data?: T;
22
+ export interface RealtimeSubscribeOptions {
23
+ connection?: RealtimeConnection;
27
24
  }
28
25
 
29
- export type SubscribeFilter = {
30
- /**
31
- * Single relation path used to match events to subscription topics.
32
- * Example: `conversation` or `embedded.author`.
33
- */
34
- field?: string;
35
- /**
36
- * Multiple relation paths used to match events to subscription topics.
37
- */
38
- fields?: string[];
39
- /**
40
- * When relation fields are provided, also include events whose `@id` matches the subscribed topics.
41
- *
42
- * Default:
43
- * - `true` when no relation fields are provided (match by `@id`)
44
- * - `false` when relation fields are provided (sub-resource mode)
45
- */
46
- includeSelf?: boolean;
47
- };
26
+ /** A Mercure update received on the SSE connection. */
27
+ export interface MercureUpdate {
28
+ /** Update ID assigned by the hub (SSE `id`). */
29
+ id?: string;
30
+ /** Parsed JSON payload. */
31
+ data: unknown;
32
+ }
48
33
 
49
34
  export interface RealtimePort {
50
- /**
51
- * Subscribes to Mercure events for the given topics.
52
- *
53
- * Note: topics must be stable strings. Undefined values are ignored by the adapter.
54
- */
55
- subscribe$<T>(iris: Iri[], filter?: SubscribeFilter, options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
35
+ /** State of the connections. */
36
+ readonly status: Signal<RealtimeStatus>;
56
37
 
57
38
  /**
58
- * Subscribes to all JSON payloads received on the given Mercure topics.
39
+ * Registers a subscription for as long as the returned Observable is subscribed. Default connection: `shared`.
59
40
  *
60
- * No payload-to-topic matching is applied.
41
+ * Mercure does not tell which subscription matched an update: the stream emits every update received on the
42
+ * connections carrying the subscription, and consumers keep the ones that concern them (e.g. by `@id`).
61
43
  */
62
- subscribeAll$<T>(iris: Iri[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
44
+ subscribe$(subscription: MercureSubscription, options?: RealtimeSubscribeOptions): Observable<MercureUpdate>;
63
45
 
64
- unsubscribe(iris: Iri[]): void;
46
+ /** Renews the authorization and reopens every connection, e.g. after login or logout. */
47
+ reconnect(): void;
65
48
 
66
49
  status$(): Observable<RealtimeStatus>;
67
- }
68
50
 
69
- export interface RealtimeDiagnosticsPort extends RealtimePort {
70
51
  diagnostics$(): Observable<RealtimeDiagnostics>;
71
52
  }
@@ -1,8 +1,8 @@
1
1
  import {HttpHeaders, HttpParams} from '@angular/common/http';
2
2
  import {Observable} from 'rxjs';
3
3
 
4
- export type Iri = string | undefined;
5
- export type IriRequired = string;
4
+ /** IRI of an API Platform resource (e.g. `/api/books/1`), or an absolute URL. */
5
+ export type Iri = string;
6
6
 
7
7
  type BaseHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';
8
8
  export type HttpMethod = BaseHttpMethod | Lowercase<BaseHttpMethod>;
@@ -10,29 +10,40 @@ export type HttpMethod = BaseHttpMethod | Lowercase<BaseHttpMethod>;
10
10
  export type QueryParamValue = string | number | boolean | Array<string | number | boolean>;
11
11
  export type AnyQuery = Query | Record<string, QueryParamValue> | HttpParams;
12
12
 
13
+ /** A resource read from the API, as described by API Platform's `HydraItemBaseSchema`. */
13
14
  export interface Item {
15
+ '@context'?: string | Record<string, unknown>;
16
+ '@id': Iri;
17
+ '@type': string;
18
+ }
19
+
20
+ /** Pagination of a collection (`view` of `HydraCollectionBaseSchema`). */
21
+ export interface View {
14
22
  '@id'?: Iri;
15
- '@context'?: string;
16
23
  '@type'?: string;
24
+ first?: Iri | null;
25
+ last?: Iri | null;
26
+ previous?: Iri | null;
27
+ next?: Iri | null;
17
28
  }
18
29
 
19
- export interface View extends Item {
20
- first?: Iri;
21
- last?: Iri;
22
- next?: Iri;
23
- previous?: Iri;
24
- }
25
- export interface IriTemplateMapping extends Item {
26
- variable: string;
27
- property?: string;
30
+ export interface IriTemplateMapping {
31
+ '@type'?: string;
32
+ variable?: string;
33
+ property?: string | null;
28
34
  required?: boolean;
29
35
  }
30
- export interface IriTemplate extends Item {
31
- template: string;
36
+
37
+ /** Filters of a collection (`search` of `HydraCollectionBaseSchema`). */
38
+ export interface IriTemplate {
39
+ '@type'?: string;
40
+ template?: string;
32
41
  variableRepresentation?: string;
33
- mapping: IriTemplateMapping[];
42
+ mapping?: IriTemplateMapping[];
34
43
  }
35
- export interface Collection<T> extends Item {
44
+
45
+ /** A collection, as described by API Platform's `HydraCollectionBaseSchema`. */
46
+ export interface Collection<T> {
36
47
  member: T[];
37
48
  totalItems?: number;
38
49
  search?: IriTemplate;
@@ -53,15 +64,15 @@ export interface HttpCallOptions {
53
64
  export interface ResourceRepository<T> {
54
65
  getCollection$(query?: AnyQuery, options?: HttpCallOptions): Observable<Collection<T>>;
55
66
 
56
- get$(iri: IriRequired, options?: HttpCallOptions): Observable<T>;
67
+ get$(iri: Iri, options?: HttpCallOptions): Observable<T>;
57
68
 
58
69
  post$(payload: Partial<T>, options?: HttpCallOptions): Observable<T>;
59
70
 
60
- patch$(iri: IriRequired, changes: Partial<T>, options?: HttpCallOptions): Observable<T>;
71
+ patch$(iri: Iri, changes: Partial<T>, options?: HttpCallOptions): Observable<T>;
61
72
 
62
- put$(iri: IriRequired, payload: Partial<T>, options?: HttpCallOptions): Observable<T>;
73
+ put$(iri: Iri, payload: Partial<T>, options?: HttpCallOptions): Observable<T>;
63
74
 
64
- delete$(iri: IriRequired, options?: HttpCallOptions): Observable<void>;
75
+ delete$(iri: Iri, options?: HttpCallOptions): Observable<void>;
65
76
 
66
77
  request$<R = unknown, B = unknown>(request: HttpRequestConfig<B>): Observable<R>;
67
78
  }
@@ -74,5 +85,4 @@ export interface HttpRequestConfig<TBody = unknown> {
74
85
  headers?: HttpHeaders | Record<string, string>;
75
86
  responseType?: 'json' | 'text' | 'blob';
76
87
  withCredentials?: boolean;
77
- options?: Record<string, unknown>;
78
88
  }
@@ -1,28 +1,24 @@
1
1
  import {EnvironmentProviders, makeEnvironmentProviders} from '@angular/core';
2
2
  import {HttpInterceptorFn, provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
3
3
  import {BridgeOptions} from './bridge-options';
4
- import {BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
5
- import {createAuthInterceptors} from './interceptors/bearer-auth.interceptor';
4
+ import {BridgeLogger} from './bridge.types';
5
+ import {createAuthInterceptor} from './interceptors/bearer-auth.interceptor';
6
6
  import {bridgeDebugInterceptor} from './interceptors/bridge-debug.interceptor';
7
7
  import {bridgeDefaultsInterceptor} from './interceptors/bridge-defaults.interceptor';
8
8
  import {contentTypeInterceptor} from './interceptors/content-type.interceptor';
9
9
  import {scopeInterceptor} from './interceptors/scoped.interceptor';
10
- import {createSingleFlightInterceptor} from './interceptors/singleflight.interceptor';
10
+ import {singleFlightInterceptor} from './interceptors/singleflight.interceptor';
11
11
  import {
12
12
  API_BASE_URL,
13
13
  BRIDGE_DEFAULTS,
14
14
  BRIDGE_HTTP_WITH_CREDENTIALS,
15
15
  BRIDGE_LOGGER,
16
- BRIDGE_MERCURE_WITH_CREDENTIALS,
17
- MERCURE_CONNECTION_MODE,
18
- MERCURE_HUB_URL,
19
- MERCURE_MAX_URL_LENGTH,
20
- MERCURE_TOPIC_MODE,
16
+ BRIDGE_MERCURE,
21
17
  } from './tokens';
22
18
 
23
19
  export type {BridgeAuth, BridgeHttpOptions, BridgeMercureOptions, BridgeOptions} from './bridge-options';
24
20
 
25
- /** Registers the bridge HTTP client, interceptors, Mercure realtime adapter and configuration tokens. */
21
+ /** Registers the bridge HTTP client, interceptors, Mercure realtime and configuration tokens. */
26
22
  export function provideBridge(options: BridgeOptions): EnvironmentProviders {
27
23
  const {
28
24
  baseUrl,
@@ -39,24 +35,19 @@ export function provideBridge(options: BridgeOptions): EnvironmentProviders {
39
35
  throw new Error("provideBridge(): missing 'baseUrl'");
40
36
  }
41
37
 
42
- const resolvedMercureInit: RequestInit = mercure?.init ?? {credentials: 'include' as RequestCredentials};
43
- const resolvedMercureHubUrl = mercure?.hubUrl;
44
- const resolvedMercureTopicMode: MercureTopicMode = mercure?.topicMode ?? 'url';
45
- const resolvedMercureConnectionMode: MercureConnectionMode = mercure?.connectionMode ?? 'auto';
46
- const resolvedMercureMaxUrlLength = normalizeMercureMaxUrlLength(mercure?.maxUrlLength, 1900);
47
- const mercureWithCredentials = resolveWithCredentials(resolvedMercureInit);
48
- const httpWithCredentials = http?.withCredentials ?? mercureWithCredentials;
49
-
50
38
  const loggerProvider: BridgeLogger = createBridgeLogger(debug);
51
39
  const scopeToBridge = (interceptor: HttpInterceptorFn) => scopeInterceptor(baseUrl, interceptor);
40
+ const authInterceptor = createAuthInterceptor(auth);
52
41
 
42
+ // Configured defaults come before the bridge headers, which are only added when absent.
43
+ // Single-flight comes after the application interceptors, so it compares the final requests.
53
44
  const interceptors: HttpInterceptorFn[] = [
54
- scopeToBridge(contentTypeInterceptor),
55
45
  scopeToBridge(bridgeDefaultsInterceptor),
56
- ...createAuthInterceptors(auth, baseUrl),
57
- ...(singleFlight ? [scopeToBridge(createSingleFlightInterceptor())] : []),
58
- ...(debug ? [scopeToBridge(bridgeDebugInterceptor)] : []),
46
+ scopeToBridge(contentTypeInterceptor),
47
+ ...(authInterceptor ? [scopeToBridge(authInterceptor)] : []),
59
48
  ...extraInterceptors,
49
+ ...(singleFlight ? [scopeToBridge(singleFlightInterceptor)] : []),
50
+ ...(debug ? [scopeToBridge(bridgeDebugInterceptor)] : []),
60
51
  ];
61
52
 
62
53
  return makeEnvironmentProviders([
@@ -65,12 +56,8 @@ export function provideBridge(options: BridgeOptions): EnvironmentProviders {
65
56
  withInterceptors(interceptors)
66
57
  ),
67
58
  {provide: API_BASE_URL, useValue: baseUrl},
68
- {provide: BRIDGE_HTTP_WITH_CREDENTIALS, useValue: httpWithCredentials},
69
- {provide: BRIDGE_MERCURE_WITH_CREDENTIALS, useValue: mercureWithCredentials},
70
- ...(resolvedMercureHubUrl ? [{provide: MERCURE_HUB_URL, useValue: resolvedMercureHubUrl}] : []),
71
- {provide: MERCURE_TOPIC_MODE, useValue: resolvedMercureTopicMode},
72
- {provide: MERCURE_CONNECTION_MODE, useValue: resolvedMercureConnectionMode},
73
- {provide: MERCURE_MAX_URL_LENGTH, useValue: resolvedMercureMaxUrlLength},
59
+ {provide: BRIDGE_HTTP_WITH_CREDENTIALS, useValue: http?.withCredentials ?? false},
60
+ ...(mercure?.hubUrl ? [{provide: BRIDGE_MERCURE, useValue: mercure}] : []),
74
61
  {provide: BRIDGE_DEFAULTS, useValue: defaults ?? {}},
75
62
  {provide: BRIDGE_LOGGER, useValue: loggerProvider},
76
63
  ]);
@@ -85,14 +72,3 @@ function createBridgeLogger(debug: boolean): BridgeLogger {
85
72
  error: console.error.bind(console),
86
73
  };
87
74
  }
88
-
89
- function resolveWithCredentials(init: RequestInit | undefined): boolean {
90
- if (!init) return false;
91
- const anyInit = init as RequestInit & {withCredentials?: boolean};
92
- return anyInit.withCredentials === true || init.credentials === 'include';
93
- }
94
-
95
- function normalizeMercureMaxUrlLength(input: unknown, fallback: number): number {
96
- const value = typeof input === 'number' ? Math.floor(input) : fallback;
97
- return Number.isFinite(value) && value >= 256 ? value : fallback;
98
- }
@@ -1,11 +1,12 @@
1
1
  import {InjectionToken} from '@angular/core';
2
- import {BridgeDefaults, BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
2
+ import {BridgeDefaults, BridgeLogger} from './bridge.types';
3
+ import {BridgeMercureOptions} from './bridge-options';
3
4
 
4
5
  /** Base URL of the API (e.g. `http://localhost:8000`). */
5
6
  export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
6
7
 
7
- /** Mercure hub URL (e.g. `http://localhost:8000/.well-known/mercure`). */
8
- export const MERCURE_HUB_URL = new InjectionToken<string>('MERCURE_HUB_URL');
8
+ /** Mercure options; absent when realtime is disabled. */
9
+ export const BRIDGE_MERCURE = new InjectionToken<BridgeMercureOptions>('BRIDGE_MERCURE');
9
10
 
10
11
  /**
11
12
  * Default credential policy for HTTP requests.
@@ -13,18 +14,6 @@ export const MERCURE_HUB_URL = new InjectionToken<string>('MERCURE_HUB_URL');
13
14
  */
14
15
  export const BRIDGE_HTTP_WITH_CREDENTIALS = new InjectionToken<boolean>('BRIDGE_HTTP_WITH_CREDENTIALS');
15
16
 
16
- /**
17
- * Default credential policy for Mercure EventSource.
18
- * When `true`, the bridge opens SSE connections with cookies enabled.
19
- */
20
- export const BRIDGE_MERCURE_WITH_CREDENTIALS = new InjectionToken<boolean>('BRIDGE_MERCURE_WITH_CREDENTIALS');
21
-
22
- export const MERCURE_TOPIC_MODE = new InjectionToken<MercureTopicMode>('MERCURE_TOPIC_MODE');
23
-
24
- export const MERCURE_CONNECTION_MODE = new InjectionToken<MercureConnectionMode>('MERCURE_CONNECTION_MODE');
25
-
26
- export const MERCURE_MAX_URL_LENGTH = new InjectionToken<number>('MERCURE_MAX_URL_LENGTH');
27
-
28
17
  export const BRIDGE_LOGGER = new InjectionToken<BridgeLogger>('BRIDGE_LOGGER');
29
18
 
30
19
  export const BRIDGE_DEFAULTS = new InjectionToken<BridgeDefaults>('BRIDGE_DEFAULTS');
@@ -1,3 +1,5 @@
1
+ import {isAbsoluteHttpUrl, trimTrailingSlash} from './url';
2
+
1
3
  type UrlKind = 'absolute' | 'protocol-relative' | 'relative';
2
4
 
3
5
  /** Returns whether a request belongs to the configured API origin and path. */
@@ -13,7 +15,7 @@ export function isRequestWithinBaseUrl(requestUrl: string, baseUrl: string): boo
13
15
 
14
16
  if (base.origin !== request.origin) return false;
15
17
 
16
- const basePath = trimTrailingSlash(base.pathname);
18
+ const basePath = trimTrailingSlash(base.pathname) || '/';
17
19
  return basePath === '/' || request.pathname === basePath || request.pathname.startsWith(`${basePath}/`);
18
20
  } catch {
19
21
  return false;
@@ -21,11 +23,7 @@ export function isRequestWithinBaseUrl(requestUrl: string, baseUrl: string): boo
21
23
  }
22
24
 
23
25
  function classifyUrl(url: string): UrlKind {
24
- if (/^https?:\/\//i.test(url)) return 'absolute';
26
+ if (isAbsoluteHttpUrl(url)) return 'absolute';
25
27
  if (url.startsWith('//')) return 'protocol-relative';
26
28
  return 'relative';
27
29
  }
28
-
29
- function trimTrailingSlash(pathname: string): string {
30
- return pathname === '/' ? pathname : pathname.replace(/\/+$/, '') || '/';
31
- }
@@ -1,15 +1,38 @@
1
- export function joinUrl(base: string, path: string): string {
2
- const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
3
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
4
- return `${normalizedBase}${normalizedPath}`;
1
+ const ABSOLUTE_HTTP_URL = /^https?:\/\//i;
2
+
3
+ export function isAbsoluteHttpUrl(url: string): boolean {
4
+ return ABSOLUTE_HTTP_URL.test(url);
5
+ }
6
+
7
+ export function trimTrailingSlash(path: string): string {
8
+ return path.replace(/\/+$/, '');
5
9
  }
6
10
 
11
+ const URL_ORIGIN = /^(?:https?:)?\/\/[^/?#]*/i;
12
+
7
13
  /**
8
- * Resolves an API IRI (e.g. `/api/books/1`) to a full URL using the API base.
9
- * If `path` is already absolute (http/https), it is returned as-is.
14
+ * Resolves a path against the API base, with one rule for requests and Mercure topics:
15
+ * - an absolute (`http(s)://`) or protocol-relative (`//host`) URL is returned as-is;
16
+ * - a path starting with `/` is relative to the origin of the base, like the IRIs API Platform returns
17
+ * (`/api/books/1` against `https://example.com/backend` → `https://example.com/api/books/1`);
18
+ * - any other path is relative to the base (`books/1` → `https://example.com/backend/books/1`).
19
+ *
20
+ * Plain string operations: URL Pattern syntax (`:id`, `{…}`, `*`) is kept unencoded.
10
21
  */
11
22
  export function resolveUrl(base: string, path: string): string {
12
- if (/^https?:\/\//i.test(path)) return path;
13
- if (path.startsWith('//')) return path;
14
- return joinUrl(base, path);
23
+ if (isAbsoluteHttpUrl(path) || path.startsWith('//')) return path;
24
+ if (path.startsWith('/')) return `${URL_ORIGIN.exec(base)?.[0] ?? ''}${path}`;
25
+ return `${trimTrailingSlash(base)}/${path}`;
26
+ }
27
+
28
+ /**
29
+ * Resolves `url` against `base` (e.g. `/` against the document base → `https://app.example.com/`).
30
+ * Returns `url` unchanged when no absolute URL can be built (e.g. no document base on the server).
31
+ */
32
+ export function toAbsoluteUrl(url: string, base: string | undefined): string {
33
+ try {
34
+ return new URL(url, base).toString();
35
+ } catch {
36
+ return url;
37
+ }
15
38
  }
@@ -6,6 +6,5 @@ export * from './lib/facades/resource.facade';
6
6
  export * from './lib/facades/facade.interface';
7
7
  export * from './lib/ports/realtime.port';
8
8
  export * from './lib/ports/resource-repository.port';
9
- export * from './lib/utils/url';
10
9
 
11
10
  export * from './models';
@@ -19,9 +19,6 @@
19
19
  "include": [
20
20
  "src/**/*.ts"
21
21
  ],
22
- "exclude": [
23
- "**/*.spec.ts"
24
- ],
25
22
  "angularCompilerOptions": {
26
23
  "compilationMode": "partial",
27
24
  "strictTemplates": true
@@ -1,71 +0,0 @@
1
- import fs from 'node:fs/promises';
2
-
3
- function stripJsonComments(input) {
4
- let out = '';
5
- let i = 0;
6
- let inString = false;
7
- let stringQuote = '"';
8
- while (i < input.length) {
9
- const ch = input[i];
10
-
11
- if (inString) {
12
- out += ch;
13
- if (ch === '\\') {
14
- // skip escaped char
15
- i += 2;
16
- continue;
17
- }
18
- if (ch === stringQuote) inString = false;
19
- i += 1;
20
- continue;
21
- }
22
-
23
- if (ch === '"' || ch === "'") {
24
- inString = true;
25
- stringQuote = ch;
26
- out += ch;
27
- i += 1;
28
- continue;
29
- }
30
-
31
- // line comment
32
- if (ch === '/' && input[i + 1] === '/') {
33
- i += 2;
34
- while (i < input.length && input[i] !== '\n') i += 1;
35
- continue;
36
- }
37
-
38
- // block comment
39
- if (ch === '/' && input[i + 1] === '*') {
40
- i += 2;
41
- while (i < input.length && !(input[i] === '*' && input[i + 1] === '/')) i += 1;
42
- i += 2;
43
- continue;
44
- }
45
-
46
- out += ch;
47
- i += 1;
48
- }
49
- return out;
50
- }
51
-
52
- export async function readJson(filePath) {
53
- const raw = await fs.readFile(filePath, 'utf8');
54
- try {
55
- return JSON.parse(raw);
56
- } catch {
57
- return JSON.parse(stripJsonComments(raw));
58
- }
59
- }
60
-
61
- export async function writeJsonIfChanged(filePath, data) {
62
- const next = JSON.stringify(data, null, 2) + '\n';
63
- try {
64
- const prev = await fs.readFile(filePath, 'utf8');
65
- if (prev === next) return false;
66
- } catch {
67
- // ignore (file missing / unreadable)
68
- }
69
- await fs.writeFile(filePath, next, 'utf8');
70
- return true;
71
- }