@obsidiane/meridiane 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -24
- package/package.json +3 -3
- package/templates/_lib_template/README.md +77 -8
- package/templates/_lib_template/package.json +2 -2
- package/templates/_lib_template/src/lib/bridge/sse/mercure.adapter.ts +635 -90
- package/templates/_lib_template/src/lib/bridge.types.ts +37 -0
- package/templates/_lib_template/src/lib/facades/bridge.facade.ts +77 -2
- package/templates/_lib_template/src/lib/facades/facade.interface.ts +2 -1
- package/templates/_lib_template/src/lib/facades/resource.facade.ts +8 -6
- package/templates/_lib_template/src/lib/ports/realtime.port.ts +33 -2
- package/templates/_lib_template/src/lib/provide-bridge.ts +23 -1
- package/templates/_lib_template/src/lib/tokens.ts +5 -1
- package/tools/app/bridge-workflow.js +8 -0
- package/tools/infra/dev-support.js +77 -0
- package/tools/infra/standalone-workspace.js +49 -15
|
@@ -31,3 +31,40 @@ export interface BridgeDefaults {
|
|
|
31
31
|
*/
|
|
32
32
|
export type MercureTopicMode = 'url' | 'iri';
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Controls how SSE connections are managed.
|
|
36
|
+
* - `single`: each watch subscription opens its own SSE connection
|
|
37
|
+
* - `auto`: subscriptions are shared and split into multiple connections only when URL length is too large
|
|
38
|
+
*/
|
|
39
|
+
export type MercureConnectionMode = 'single' | 'auto';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-subscription realtime override.
|
|
43
|
+
* - `true`: force an isolated SSE connection for this watch call
|
|
44
|
+
* - `false`/`undefined`: use adapter strategy (`single` or `auto`)
|
|
45
|
+
*/
|
|
46
|
+
export interface WatchConnectionOptions {
|
|
47
|
+
newConnection?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RealtimeConnectionScope = 'shared' | 'dedicated';
|
|
51
|
+
|
|
52
|
+
export type RealtimeConnectionStatus = 'connecting' | 'connected' | 'closed';
|
|
53
|
+
|
|
54
|
+
export interface RealtimeConnectionDiagnostics {
|
|
55
|
+
id: string;
|
|
56
|
+
scope: RealtimeConnectionScope;
|
|
57
|
+
status: RealtimeConnectionStatus;
|
|
58
|
+
topicCount: number;
|
|
59
|
+
topics: string[];
|
|
60
|
+
urlLength: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RealtimeDiagnostics {
|
|
64
|
+
mode: MercureConnectionMode;
|
|
65
|
+
maxUrlLength: number;
|
|
66
|
+
totalConnections: number;
|
|
67
|
+
sharedConnections: number;
|
|
68
|
+
dedicatedConnections: number;
|
|
69
|
+
connections: RealtimeConnectionDiagnostics[];
|
|
70
|
+
}
|
|
@@ -9,6 +9,22 @@ import {API_BASE_URL, BRIDGE_WITH_CREDENTIALS} from '../tokens';
|
|
|
9
9
|
import {AnyQuery, Collection, HttpCallOptions, HttpRequestConfig, Iri, IriRequired, Item} from '../ports/resource-repository.port';
|
|
10
10
|
import {SubscribeFilter} from '../ports/realtime.port';
|
|
11
11
|
import {resolveUrl} from '../utils/url';
|
|
12
|
+
import {RealtimeDiagnostics, WatchConnectionOptions} from '../bridge.types';
|
|
13
|
+
|
|
14
|
+
export type TypedEvent<TType extends string = string, TPayload = unknown> = {
|
|
15
|
+
resourceType: TType;
|
|
16
|
+
payload: TPayload;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WatchTypesResult<R extends Record<string, any>> =
|
|
20
|
+
{ [K in keyof R]: TypedEvent<K & string, R[K]> }[keyof R];
|
|
21
|
+
|
|
22
|
+
export type WatchTypesConfig = {
|
|
23
|
+
/** Field name used as discriminator. Default: `@type` (JSON-LD). */
|
|
24
|
+
discriminator?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WatchSubscriptionOptions = WatchConnectionOptions;
|
|
12
28
|
|
|
13
29
|
/**
|
|
14
30
|
* High-level facade for ad-hoc HTTP calls and Mercure subscriptions.
|
|
@@ -85,10 +101,14 @@ export class BridgeFacade {
|
|
|
85
101
|
|
|
86
102
|
// ──────────────── SSE / Mercure ────────────────
|
|
87
103
|
|
|
88
|
-
watch$<T = Item>(
|
|
104
|
+
watch$<T = Item>(
|
|
105
|
+
iri: Iri | Iri[],
|
|
106
|
+
subscribeFilter?: SubscribeFilter,
|
|
107
|
+
options?: WatchSubscriptionOptions
|
|
108
|
+
): Observable<T> {
|
|
89
109
|
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
90
110
|
return this.realtime
|
|
91
|
-
.subscribe$<T>(iris, subscribeFilter)
|
|
111
|
+
.subscribe$<T>(iris, subscribeFilter, options)
|
|
92
112
|
.pipe(
|
|
93
113
|
map((event) => event.data),
|
|
94
114
|
filter((data): data is T => data !== undefined),
|
|
@@ -96,13 +116,68 @@ export class BridgeFacade {
|
|
|
96
116
|
);
|
|
97
117
|
}
|
|
98
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Watches a "multi-entity" topic and emits only payloads whose discriminator matches `resourceTypes`.
|
|
121
|
+
*
|
|
122
|
+
* Important:
|
|
123
|
+
* - filtering is done on payload content (e.g. `@type`), not on network topic provenance.
|
|
124
|
+
* - in shared mode (`connectionMode: 'auto'` without `newConnection`), events from another shared topic
|
|
125
|
+
* can be emitted if they carry an allowed discriminator.
|
|
126
|
+
*
|
|
127
|
+
* For strict single-topic isolation, use a dedicated connection:
|
|
128
|
+
* `watchTypes$(..., ..., ..., { newConnection: true })`
|
|
129
|
+
* (or `connectionMode: 'single'` globally).
|
|
130
|
+
*/
|
|
131
|
+
watchTypes$<R extends Record<string, any>>(
|
|
132
|
+
iri: Iri | Iri[],
|
|
133
|
+
resourceTypes: (keyof R & string)[],
|
|
134
|
+
cfg?: WatchTypesConfig,
|
|
135
|
+
options?: WatchSubscriptionOptions
|
|
136
|
+
): Observable<WatchTypesResult<R>> {
|
|
137
|
+
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
138
|
+
const discriminator = cfg?.discriminator ?? '@type';
|
|
139
|
+
const allowedTypes = new Set(resourceTypes.filter((v): v is string => typeof v === 'string' && v.length > 0));
|
|
140
|
+
|
|
141
|
+
return this.realtime
|
|
142
|
+
.subscribeAll$<unknown>(iris, options)
|
|
143
|
+
.pipe(
|
|
144
|
+
map((event): WatchTypesResult<R> | undefined => {
|
|
145
|
+
const raw = event.data;
|
|
146
|
+
|
|
147
|
+
const resolvedType = readDiscriminator(raw, discriminator);
|
|
148
|
+
if (typeof resolvedType !== 'string') return undefined;
|
|
149
|
+
if (!allowedTypes.has(resolvedType)) return undefined;
|
|
150
|
+
|
|
151
|
+
const key = resolvedType as keyof R & string;
|
|
152
|
+
return {resourceType: key, payload: raw as R[keyof R]} as WatchTypesResult<R>;
|
|
153
|
+
}),
|
|
154
|
+
filter((evt): evt is WatchTypesResult<R> => !!evt),
|
|
155
|
+
share()
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
99
159
|
unwatch(iri: Iri | Iri[]): void {
|
|
100
160
|
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
101
161
|
this.realtime.unsubscribe(iris);
|
|
102
162
|
}
|
|
103
163
|
|
|
164
|
+
realtimeDiagnostics$(): Observable<RealtimeDiagnostics> {
|
|
165
|
+
return this.realtime.diagnostics$();
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
private resolveUrl(path?: Iri): string {
|
|
105
169
|
if (!path) throw new Error('BridgeFacade: missing url');
|
|
106
170
|
return resolveUrl(this.apiBase, path);
|
|
107
171
|
}
|
|
108
172
|
}
|
|
173
|
+
|
|
174
|
+
function readDiscriminator(raw: unknown, discriminator: string): string | undefined {
|
|
175
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
176
|
+
const value = (raw as any)?.[discriminator];
|
|
177
|
+
if (typeof value === 'string') return value;
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
const first = value.find((v) => typeof v === 'string');
|
|
180
|
+
return typeof first === 'string' ? first : undefined;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
AnyQuery,
|
|
9
9
|
} from '../ports/resource-repository.port';
|
|
10
10
|
import {Observable} from 'rxjs';
|
|
11
|
+
import {WatchConnectionOptions} from '../bridge.types';
|
|
11
12
|
|
|
12
13
|
export interface Facade<T extends Item> {
|
|
13
14
|
getCollection$(query?: AnyQuery, opts?: HttpCallOptions): Observable<Collection<T>>;
|
|
@@ -24,7 +25,7 @@ export interface Facade<T extends Item> {
|
|
|
24
25
|
|
|
25
26
|
request$<R = unknown, B = unknown>(req: HttpRequestConfig<B>): Observable<R>;
|
|
26
27
|
|
|
27
|
-
watch$(iri: Iri | Iri[]): Observable<T>;
|
|
28
|
+
watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T>;
|
|
28
29
|
|
|
29
30
|
unwatch(iri: Iri | Iri[]): void;
|
|
30
31
|
}
|
|
@@ -13,6 +13,7 @@ import {RealtimePort, RealtimeStatus} from '../ports/realtime.port';
|
|
|
13
13
|
import {toSignal} from '@angular/core/rxjs-interop';
|
|
14
14
|
import {filter, map, Observable, share} from 'rxjs';
|
|
15
15
|
import {Facade} from './facade.interface';
|
|
16
|
+
import {WatchConnectionOptions} from '../bridge.types';
|
|
16
17
|
|
|
17
18
|
export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
18
19
|
|
|
@@ -60,9 +61,9 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
|
60
61
|
* Subscribes to real-time updates for one or many IRIs.
|
|
61
62
|
* Undefined/empty values are ignored.
|
|
62
63
|
*/
|
|
63
|
-
watch$(iri: Iri | Iri[]): Observable<T> {
|
|
64
|
+
watch$(iri: Iri | Iri[], options?: WatchConnectionOptions): Observable<T> {
|
|
64
65
|
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
65
|
-
return this.subscribeAndSync(iris);
|
|
66
|
+
return this.subscribeAndSync(iris, options);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
|
|
@@ -77,11 +78,12 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
|
77
78
|
*/
|
|
78
79
|
watchSubResource$<R>(
|
|
79
80
|
iri: Iri | Iri[],
|
|
80
|
-
field: string
|
|
81
|
+
field: string,
|
|
82
|
+
options?: WatchConnectionOptions
|
|
81
83
|
): Observable<R> {
|
|
82
84
|
const iris = (Array.isArray(iri) ? iri : [iri]).filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
83
85
|
return this.realtime
|
|
84
|
-
.subscribe$<R>(iris, {field: field})
|
|
86
|
+
.subscribe$<R>(iris, {field: field}, options)
|
|
85
87
|
.pipe(
|
|
86
88
|
map(e => e.data),
|
|
87
89
|
filter((d): d is R => d !== undefined),
|
|
@@ -89,9 +91,9 @@ export class ResourceFacade<T extends Item> implements Facade<T> {
|
|
|
89
91
|
);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
protected subscribeAndSync(iris: string[]): Observable<T> {
|
|
94
|
+
protected subscribeAndSync(iris: string[], options?: WatchConnectionOptions): Observable<T> {
|
|
93
95
|
return this.realtime
|
|
94
|
-
.subscribe$<T>(iris)
|
|
96
|
+
.subscribe$<T>(iris, undefined, options)
|
|
95
97
|
.pipe(
|
|
96
98
|
map(event => event.data),
|
|
97
99
|
filter((data): data is T => data !== undefined),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {Observable} from 'rxjs';
|
|
2
2
|
import {Iri} from './resource-repository.port';
|
|
3
|
+
import {WatchConnectionOptions} from '../bridge.types';
|
|
3
4
|
|
|
4
5
|
export type RealtimeStatus = 'connecting' | 'connected' | 'closed';
|
|
5
6
|
|
|
@@ -15,11 +16,34 @@ export interface SseOptions {
|
|
|
15
16
|
|
|
16
17
|
export interface RealtimeEvent<T> {
|
|
17
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;
|
|
18
26
|
data?: T;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export type SubscribeFilter = {
|
|
22
|
-
|
|
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;
|
|
23
47
|
};
|
|
24
48
|
|
|
25
49
|
export interface RealtimePort {
|
|
@@ -28,7 +52,14 @@ export interface RealtimePort {
|
|
|
28
52
|
*
|
|
29
53
|
* Note: topics must be stable strings. Undefined values are ignored by the adapter.
|
|
30
54
|
*/
|
|
31
|
-
subscribe$<T>(iris: Iri[], filter?: SubscribeFilter): Observable<RealtimeEvent<T>>;
|
|
55
|
+
subscribe$<T>(iris: Iri[], filter?: SubscribeFilter, options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Subscribes to all JSON payloads received on the given Mercure topics.
|
|
59
|
+
*
|
|
60
|
+
* No payload-to-topic matching is applied.
|
|
61
|
+
*/
|
|
62
|
+
subscribeAll$<T>(iris: Iri[], options?: WatchConnectionOptions): Observable<RealtimeEvent<T>>;
|
|
32
63
|
|
|
33
64
|
unsubscribe(iris: Iri[]): void;
|
|
34
65
|
|
|
@@ -5,10 +5,12 @@ import {
|
|
|
5
5
|
BRIDGE_DEFAULTS,
|
|
6
6
|
BRIDGE_LOGGER,
|
|
7
7
|
BRIDGE_WITH_CREDENTIALS,
|
|
8
|
+
MERCURE_CONNECTION_MODE,
|
|
8
9
|
MERCURE_HUB_URL,
|
|
10
|
+
MERCURE_MAX_URL_LENGTH,
|
|
9
11
|
MERCURE_TOPIC_MODE,
|
|
10
12
|
} from './tokens';
|
|
11
|
-
import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
|
|
13
|
+
import {BridgeDefaults, BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
|
|
12
14
|
import {contentTypeInterceptor} from './interceptors/content-type.interceptor';
|
|
13
15
|
import {bridgeDefaultsInterceptor} from './interceptors/bridge-defaults.interceptor';
|
|
14
16
|
import {bridgeDebugInterceptor} from './interceptors/bridge-debug.interceptor';
|
|
@@ -25,6 +27,17 @@ export interface BridgeMercureOptions {
|
|
|
25
27
|
hubUrl?: string;
|
|
26
28
|
init?: RequestInit;
|
|
27
29
|
topicMode?: MercureTopicMode;
|
|
30
|
+
/**
|
|
31
|
+
* Connection strategy for SSE subscriptions.
|
|
32
|
+
* - `single`: one SSE connection per watch subscription
|
|
33
|
+
* - `auto` (default): shared connections with automatic URL-length sharding
|
|
34
|
+
*/
|
|
35
|
+
connectionMode?: MercureConnectionMode;
|
|
36
|
+
/**
|
|
37
|
+
* Maximum Mercure URL length before auto-sharding topics into multiple connections.
|
|
38
|
+
* Used only when `connectionMode: 'auto'`. Default: `1900`.
|
|
39
|
+
*/
|
|
40
|
+
maxUrlLength?: number;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
export interface BridgeOptions {
|
|
@@ -70,6 +83,8 @@ export function provideBridge(opts: BridgeOptions): EnvironmentProviders {
|
|
|
70
83
|
const resolvedMercureInit: RequestInit = mercure?.init ?? {credentials: 'include' as RequestCredentials};
|
|
71
84
|
const resolvedMercureHubUrl = mercure?.hubUrl;
|
|
72
85
|
const resolvedMercureTopicMode: MercureTopicMode = mercure?.topicMode ?? 'url';
|
|
86
|
+
const resolvedMercureConnectionMode: MercureConnectionMode = mercure?.connectionMode ?? 'auto';
|
|
87
|
+
const resolvedMercureMaxUrlLength = normalizeMercureMaxUrlLength(mercure?.maxUrlLength, 1900);
|
|
73
88
|
const withCredentials = resolveWithCredentials(resolvedMercureInit);
|
|
74
89
|
|
|
75
90
|
const loggerProvider: BridgeLogger = createBridgeLogger(debug);
|
|
@@ -92,6 +107,8 @@ export function provideBridge(opts: BridgeOptions): EnvironmentProviders {
|
|
|
92
107
|
{provide: BRIDGE_WITH_CREDENTIALS, useValue: withCredentials},
|
|
93
108
|
...(resolvedMercureHubUrl ? [{provide: MERCURE_HUB_URL, useValue: resolvedMercureHubUrl}] : []),
|
|
94
109
|
{provide: MERCURE_TOPIC_MODE, useValue: resolvedMercureTopicMode},
|
|
110
|
+
{provide: MERCURE_CONNECTION_MODE, useValue: resolvedMercureConnectionMode},
|
|
111
|
+
{provide: MERCURE_MAX_URL_LENGTH, useValue: resolvedMercureMaxUrlLength},
|
|
95
112
|
{provide: BRIDGE_DEFAULTS, useValue: defaults ?? {}},
|
|
96
113
|
{provide: BRIDGE_LOGGER, useValue: loggerProvider},
|
|
97
114
|
]);
|
|
@@ -113,6 +130,11 @@ function resolveWithCredentials(init: RequestInit | undefined): boolean {
|
|
|
113
130
|
return anyInit.withCredentials === true || init.credentials === 'include';
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
function normalizeMercureMaxUrlLength(input: unknown, fallback: number): number {
|
|
134
|
+
const value = typeof input === 'number' ? Math.floor(input) : fallback;
|
|
135
|
+
return Number.isFinite(value) && value >= 256 ? value : fallback;
|
|
136
|
+
}
|
|
137
|
+
|
|
116
138
|
function createAuthInterceptors(auth?: BridgeAuth): HttpInterceptorFn[] {
|
|
117
139
|
if (!auth) return [];
|
|
118
140
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {InjectionToken} from '@angular/core';
|
|
2
|
-
import {BridgeDefaults, BridgeLogger, MercureTopicMode} from './bridge.types';
|
|
2
|
+
import {BridgeDefaults, BridgeLogger, MercureConnectionMode, MercureTopicMode} from './bridge.types';
|
|
3
3
|
|
|
4
4
|
/** Base URL of the API (e.g. `http://localhost:8000`). */
|
|
5
5
|
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
|
|
@@ -15,6 +15,10 @@ export const BRIDGE_WITH_CREDENTIALS = new InjectionToken<boolean>('BRIDGE_WITH_
|
|
|
15
15
|
|
|
16
16
|
export const MERCURE_TOPIC_MODE = new InjectionToken<MercureTopicMode>('MERCURE_TOPIC_MODE');
|
|
17
17
|
|
|
18
|
+
export const MERCURE_CONNECTION_MODE = new InjectionToken<MercureConnectionMode>('MERCURE_CONNECTION_MODE');
|
|
19
|
+
|
|
20
|
+
export const MERCURE_MAX_URL_LENGTH = new InjectionToken<number>('MERCURE_MAX_URL_LENGTH');
|
|
21
|
+
|
|
18
22
|
export const BRIDGE_LOGGER = new InjectionToken<BridgeLogger>('BRIDGE_LOGGER');
|
|
19
23
|
|
|
20
24
|
export const BRIDGE_DEFAULTS = new InjectionToken<BridgeDefaults>('BRIDGE_DEFAULTS');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
3
4
|
|
|
4
5
|
import { readOpenApiSpec } from '../infra/spec-loader.js';
|
|
5
6
|
import { generateBridgeWorkspace } from '../infra/generate/bridge.js';
|
|
@@ -69,6 +70,7 @@ export async function runBridgeWorkflow({
|
|
|
69
70
|
const distDir = path.join(distRoot, libName);
|
|
70
71
|
const packRel = packRelativeTo ? path.relative(packRelativeTo, distDir) : distDir;
|
|
71
72
|
log?.step?.(`npm pack (${packRel})`);
|
|
73
|
+
await cleanupPackedTarballs(distDir);
|
|
72
74
|
const packCode = await runCommand('npm', ['pack'], {
|
|
73
75
|
cwd: distDir,
|
|
74
76
|
env: {
|
|
@@ -83,3 +85,9 @@ export async function runBridgeWorkflow({
|
|
|
83
85
|
|
|
84
86
|
return { workspaceRoot, distRoot, distDir, npmCacheDir };
|
|
85
87
|
}
|
|
88
|
+
|
|
89
|
+
async function cleanupPackedTarballs(distDir) {
|
|
90
|
+
const entries = await fs.readdir(distDir, {withFileTypes: true});
|
|
91
|
+
const tarballs = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.tgz'));
|
|
92
|
+
await Promise.all(tarballs.map((entry) => fs.rm(path.join(distDir, entry.name), {force: true})));
|
|
93
|
+
}
|
|
@@ -108,5 +108,82 @@ export async function installPackageFromDist({ packageName, distDir, appRoot, lo
|
|
|
108
108
|
filter: (src) => !src.endsWith('.tgz'),
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
await invalidateAngularViteDependencyCache({ appRoot, packageName, log });
|
|
112
|
+
|
|
111
113
|
return pkgDir;
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
async function invalidateAngularViteDependencyCache({ appRoot, packageName, log }) {
|
|
117
|
+
const viteDepsRoots = await findAngularViteDepsRoots(appRoot);
|
|
118
|
+
if (viteDepsRoots.length === 0) return;
|
|
119
|
+
|
|
120
|
+
const cacheBase = packageNameToViteCacheBase(packageName);
|
|
121
|
+
let removed = 0;
|
|
122
|
+
|
|
123
|
+
for (const depsRoot of viteDepsRoots) {
|
|
124
|
+
const files = [
|
|
125
|
+
`${cacheBase}.js`,
|
|
126
|
+
`${cacheBase}.js.map`,
|
|
127
|
+
`${cacheBase}.mjs`,
|
|
128
|
+
`${cacheBase}.mjs.map`,
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const fileName of files) {
|
|
132
|
+
const p = path.join(depsRoot, fileName);
|
|
133
|
+
try {
|
|
134
|
+
await fs.rm(p, { force: true });
|
|
135
|
+
removed += 1;
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Metadata keeps dependency hashes/entries; remove it to force a clean re-optimization.
|
|
142
|
+
try {
|
|
143
|
+
await fs.rm(path.join(depsRoot, '_metadata.json'), { force: true });
|
|
144
|
+
removed += 1;
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (removed > 0) {
|
|
151
|
+
log?.info?.(`cache Angular/Vite invalidé (${removed} fichier(s)) pour ${packageName}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function packageNameToViteCacheBase(packageName) {
|
|
156
|
+
const normalized = String(packageName || '').trim();
|
|
157
|
+
// Example: "@obsidiane/bridge-sandbox" -> "@obsidiane_bridge-sandbox"
|
|
158
|
+
return normalized.replace('/', '_');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function findAngularViteDepsRoots(appRoot) {
|
|
162
|
+
const angularCacheRoot = path.join(appRoot, '.angular', 'cache');
|
|
163
|
+
const roots = [];
|
|
164
|
+
|
|
165
|
+
async function walk(dir, depth) {
|
|
166
|
+
if (depth > 8) return;
|
|
167
|
+
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const hasDeps = entries.some((e) => e.isDirectory() && e.name === 'deps');
|
|
176
|
+
if (hasDeps && path.basename(dir) === 'vite') {
|
|
177
|
+
roots.push(path.join(dir, 'deps'));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (!entry.isDirectory()) continue;
|
|
182
|
+
if (entry.name === 'node_modules') continue;
|
|
183
|
+
await walk(path.join(dir, entry.name), depth + 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await walk(angularCacheRoot, 0);
|
|
188
|
+
return roots;
|
|
189
|
+
}
|
|
@@ -18,7 +18,7 @@ async function exists(p) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function toolchainPackageJson({ angularVersion }) {
|
|
21
|
+
function toolchainPackageJson({ angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion }) {
|
|
22
22
|
const v = angularVersion;
|
|
23
23
|
return {
|
|
24
24
|
name: 'meridiane-standalone-workspace',
|
|
@@ -29,10 +29,10 @@ function toolchainPackageJson({ angularVersion }) {
|
|
|
29
29
|
'@angular/compiler': v,
|
|
30
30
|
'@angular/compiler-cli': v,
|
|
31
31
|
'@angular/core': v,
|
|
32
|
-
'ng-packagr':
|
|
33
|
-
rxjs:
|
|
34
|
-
tslib:
|
|
35
|
-
typescript:
|
|
32
|
+
'ng-packagr': ngPackagrVersion,
|
|
33
|
+
rxjs: rxjsVersion,
|
|
34
|
+
tslib: tslibVersion,
|
|
35
|
+
typescript: typescriptVersion,
|
|
36
36
|
},
|
|
37
37
|
};
|
|
38
38
|
}
|
|
@@ -42,7 +42,9 @@ function rootTsconfigJson() {
|
|
|
42
42
|
compilerOptions: {
|
|
43
43
|
target: 'ES2022',
|
|
44
44
|
module: 'ES2022',
|
|
45
|
-
|
|
45
|
+
// Angular >=21 relies on modern package exports resolution for some subpaths
|
|
46
|
+
// (e.g. @angular/common/http, @angular/core/rxjs-interop).
|
|
47
|
+
moduleResolution: 'bundler',
|
|
46
48
|
lib: ['ES2022', 'DOM'],
|
|
47
49
|
strict: true,
|
|
48
50
|
skipLibCheck: true,
|
|
@@ -63,9 +65,18 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
|
|
|
63
65
|
await ensureDir(distRoot);
|
|
64
66
|
await ensureDir(workspaceRoot);
|
|
65
67
|
|
|
66
|
-
const
|
|
68
|
+
const detected = await detectToolchainVersions(repoRoot);
|
|
69
|
+
const angularVersion = detected.angularVersion ?? '21.1.4';
|
|
70
|
+
const derived = deriveToolchainDefaultsFromAngular(angularVersion);
|
|
71
|
+
const ngPackagrVersion = detected.ngPackagrVersion ?? derived.ngPackagrVersion;
|
|
72
|
+
const typescriptVersion = detected.typescriptVersion ?? derived.typescriptVersion;
|
|
73
|
+
const rxjsVersion = detected.rxjsVersion ?? '7.8.2';
|
|
74
|
+
const tslibVersion = detected.tslibVersion ?? '2.8.1';
|
|
67
75
|
log?.debug?.('[meridiane] angular version', { angularVersion });
|
|
68
|
-
await writeJsonIfChanged(
|
|
76
|
+
await writeJsonIfChanged(
|
|
77
|
+
path.join(workspaceRoot, 'package.json'),
|
|
78
|
+
toolchainPackageJson({ angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion })
|
|
79
|
+
);
|
|
69
80
|
await writeJsonIfChanged(path.join(workspaceRoot, 'tsconfig.json'), rootTsconfigJson());
|
|
70
81
|
|
|
71
82
|
const npmCacheDir = path.join(workspaceRoot, '.npm-cache');
|
|
@@ -76,21 +87,44 @@ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
|
|
|
76
87
|
return { workspaceRoot, distRoot, npmCacheDir, requireFromWs };
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
async function
|
|
90
|
+
async function detectToolchainVersions(repoRoot) {
|
|
80
91
|
const pkgPath = path.join(repoRoot, 'package.json');
|
|
81
92
|
try {
|
|
82
93
|
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
83
94
|
const parsed = JSON.parse(raw);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
95
|
+
const angularVersion = readDependencyVersion(parsed, '@angular/core');
|
|
96
|
+
const ngPackagrVersion = readDependencyVersion(parsed, 'ng-packagr');
|
|
97
|
+
const typescriptVersion = readDependencyVersion(parsed, 'typescript');
|
|
98
|
+
const rxjsVersion = readDependencyVersion(parsed, 'rxjs');
|
|
99
|
+
const tslibVersion = readDependencyVersion(parsed, 'tslib');
|
|
100
|
+
return { angularVersion, ngPackagrVersion, typescriptVersion, rxjsVersion, tslibVersion };
|
|
89
101
|
} catch {
|
|
90
|
-
return
|
|
102
|
+
return {};
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
function readDependencyVersion(pkg, depName) {
|
|
107
|
+
const deps = pkg?.dependencies || {};
|
|
108
|
+
const devDeps = pkg?.devDependencies || {};
|
|
109
|
+
const peerDeps = pkg?.peerDependencies || {};
|
|
110
|
+
const value = deps[depName] ?? devDeps[depName] ?? peerDeps[depName];
|
|
111
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function deriveToolchainDefaultsFromAngular(angularVersion) {
|
|
115
|
+
const major = parseInt(String(angularVersion).match(/\d+/)?.[0] ?? '0', 10);
|
|
116
|
+
if (major >= 21) {
|
|
117
|
+
return {
|
|
118
|
+
ngPackagrVersion: '21.1.0',
|
|
119
|
+
typescriptVersion: '5.9.3',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
ngPackagrVersion: '20.1.0',
|
|
124
|
+
typescriptVersion: '5.8.3',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
export async function ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log }) {
|
|
95
129
|
// Prefer using existing (hoisted) deps during local dev; in CI this will trigger installation.
|
|
96
130
|
const canResolve = (id) => {
|