@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.
- package/README.md +30 -68
- package/cli.js +1 -10
- package/package.json +1 -1
- package/src/application/bridge-workflow.js +23 -66
- package/src/application/generate-bridge.js +32 -36
- package/src/application/run-build.js +8 -30
- package/src/application/run-dev.js +8 -63
- package/src/application/run-generate.js +8 -47
- package/src/domain/bridge-options.js +10 -63
- package/src/generators/bridge/write-bridge-files.js +11 -29
- package/src/generators/models/interface-schema.js +3 -20
- package/src/generators/models/model-selection.js +67 -106
- package/src/generators/models/oas-contract.js +1 -108
- package/src/generators/models/oas-schema-name.js +1 -19
- package/src/generators/models/openapi-to-models.js +51 -42
- package/src/generators/models/schema-utils.js +0 -34
- package/src/generators/models/type-resolver.js +2 -20
- package/src/generators/models/utils.js +0 -11
- package/src/generators/models/write-model-files.js +5 -15
- package/src/infrastructure/dev-project.js +0 -33
- package/src/infrastructure/files.js +15 -0
- package/src/infrastructure/local-package-install.js +2 -2
- package/src/infrastructure/logger.js +0 -2
- package/templates/angular-bridge/README.md +29 -281
- package/templates/angular-bridge/package.json +2 -2
- package/templates/angular-bridge/src/lib/bridge/rest/http-request.options.ts +10 -19
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-hub.ts +449 -0
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.ts +28 -0
- package/templates/angular-bridge/src/lib/bridge/sse/resource-topic.ts +83 -0
- package/templates/angular-bridge/src/lib/bridge-options.ts +21 -17
- package/templates/angular-bridge/src/lib/bridge.types.ts +22 -33
- package/templates/angular-bridge/src/lib/facades/bridge.facade.ts +58 -134
- package/templates/angular-bridge/src/lib/facades/facade.factory.ts +25 -25
- package/templates/angular-bridge/src/lib/facades/facade.interface.ts +4 -9
- package/templates/angular-bridge/src/lib/facades/resource.facade.ts +38 -61
- package/templates/angular-bridge/src/lib/interceptors/bearer-auth.interceptor.ts +8 -12
- package/templates/angular-bridge/src/lib/interceptors/bridge-defaults.interceptor.ts +19 -11
- package/templates/angular-bridge/src/lib/interceptors/content-type.interceptor.ts +5 -17
- package/templates/angular-bridge/src/lib/interceptors/singleflight.interceptor.ts +28 -24
- package/templates/angular-bridge/src/lib/ports/realtime.port.ts +34 -53
- package/templates/angular-bridge/src/lib/ports/resource-repository.port.ts +31 -21
- package/templates/angular-bridge/src/lib/provide-bridge.ts +14 -38
- package/templates/angular-bridge/src/lib/tokens.ts +4 -15
- package/templates/angular-bridge/src/lib/utils/request-url.ts +4 -6
- package/templates/angular-bridge/src/lib/utils/url.ts +32 -9
- package/templates/angular-bridge/src/public-api.ts +0 -1
- package/templates/angular-bridge/tsconfig.lib.json +0 -3
- package/src/infrastructure/json.js +0 -71
- package/templates/angular-bridge/src/lib/bridge/rest/api-platform.adapter.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/eventsource-wrapper.ts +0 -70
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-connections.ts +0 -255
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-payload.ts +0 -82
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-topic.mapper.ts +0 -48
- package/templates/angular-bridge/src/lib/bridge/sse/mercure-url.builder.ts +0 -39
- package/templates/angular-bridge/src/lib/bridge/sse/mercure.adapter.ts +0 -358
- package/templates/angular-bridge/src/lib/bridge/sse/ref-count-topic.registry.ts +0 -36
- package/templates/angular-bridge/src/lib/bridge/sse/subscription-claims.ts +0 -76
- 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
|
-
|
|
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:
|
|
19
|
-
PUT:
|
|
20
|
-
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',
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
inFlight.
|
|
27
|
-
|
|
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 {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export interface
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
+
* Registers a subscription for as long as the returned Observable is subscribed. Default connection: `shared`.
|
|
59
40
|
*
|
|
60
|
-
*
|
|
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
|
-
|
|
44
|
+
subscribe$(subscription: MercureSubscription, options?: RealtimeSubscribeOptions): Observable<MercureUpdate>;
|
|
63
45
|
|
|
64
|
-
|
|
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
|
-
|
|
5
|
-
export type
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
42
|
+
mapping?: IriTemplateMapping[];
|
|
34
43
|
}
|
|
35
|
-
|
|
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:
|
|
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:
|
|
71
|
+
patch$(iri: Iri, changes: Partial<T>, options?: HttpCallOptions): Observable<T>;
|
|
61
72
|
|
|
62
|
-
put$(iri:
|
|
73
|
+
put$(iri: Iri, payload: Partial<T>, options?: HttpCallOptions): Observable<T>;
|
|
63
74
|
|
|
64
|
-
delete$(iri:
|
|
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
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
...(
|
|
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:
|
|
69
|
-
{provide:
|
|
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
|
|
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
|
|
8
|
-
export const
|
|
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 (
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
return
|
|
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
|
|
9
|
-
*
|
|
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 (
|
|
13
|
-
if (path.startsWith('
|
|
14
|
-
return
|
|
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
|
}
|
|
@@ -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
|
-
}
|