@opensourcekd/ng-common-libs 2.0.11 → 2.2.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 CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  - **AuthService**: Complete Auth0 authentication with token management (Pure TypeScript - no Angular dependency)
25
25
  - **EventBus**: Framework-agnostic event bus using RxJS
26
26
  - **Logger**: OpenTelemetry-compliant structured logging with observable log streams
27
+ - **BearerTokenInterceptor**: Singleton fetch interceptor that attaches bearer tokens to API requests
28
+ - **CSS Tokens**: Shared design tokens (colors, typography, spacing, shadows, and more) as CSS custom properties
27
29
  - **Works with any framework**: Angular, React, Vue, Svelte, vanilla JS
28
30
  - **Module Federation Support**: Singleton pattern works across shell + MFEs
29
31
  - **Zero Framework Lock-in**: Core services are pure TypeScript classes
@@ -92,6 +94,53 @@ All documentation is in the `/docs` folder:
92
94
  - **[MICROAPP_SETUP_REQUIRED.md](./docs/MICROAPP_SETUP_REQUIRED.md)** - Microapp setup
93
95
  - **[COPILOT_EFFICIENCY.md](./docs/COPILOT_EFFICIENCY.md)** - ⚡ How to use GitHub Copilot efficiently
94
96
 
97
+ ## 🎨 CSS Design Tokens
98
+
99
+ Shared CSS custom properties for consistent styling across all micro-frontends.
100
+
101
+ ### Usage
102
+
103
+ In your global stylesheet (`styles.css` / `styles.scss`):
104
+
105
+ ```css
106
+ @import '@opensourcekd/ng-common-libs/styles/tokens.css';
107
+ ```
108
+
109
+ ### Available Token Categories
110
+
111
+ | Category | Example Tokens |
112
+ |---|---|
113
+ | **Colors** | `--color-primary-500`, `--color-success-600`, `--color-error-500` |
114
+ | **Semantic Colors** | `--color-brand`, `--color-text-primary`, `--color-bg-surface` |
115
+ | **Typography** | `--font-size-md`, `--font-weight-bold`, `--line-height-normal` |
116
+ | **Spacing** | `--space-4` (16px), `--space-8` (32px), `--space-16` (64px) |
117
+ | **Border Radius** | `--radius-sm`, `--radius-lg`, `--radius-full` |
118
+ | **Shadows** | `--shadow-sm`, `--shadow-md`, `--shadow-xl`, `--shadow-focus` |
119
+ | **Z-index** | `--z-index-modal`, `--z-index-toast`, `--z-index-tooltip` |
120
+ | **Transitions** | `--transition-fast`, `--transition-normal`, `--duration-fast` |
121
+ | **Opacity** | `--opacity-50`, `--opacity-75`, `--opacity-100` |
122
+ | **Breakpoints** | `--breakpoint-sm` (640px), `--breakpoint-lg` (1024px) |
123
+
124
+ ### Example
125
+
126
+ ```css
127
+ .my-button {
128
+ background-color: var(--color-brand);
129
+ color: var(--color-text-on-brand);
130
+ padding: var(--space-2) var(--space-4);
131
+ border-radius: var(--radius-md);
132
+ font-size: var(--font-size-sm);
133
+ font-weight: var(--font-weight-semibold);
134
+ box-shadow: var(--shadow-sm);
135
+ transition: var(--transition-normal);
136
+ }
137
+
138
+ .my-button:hover {
139
+ background-color: var(--color-brand-hover);
140
+ box-shadow: var(--shadow-md);
141
+ }
142
+ ```
143
+
95
144
  ## 📝 What's Exported
96
145
 
97
146
  Everything you need from one import:
@@ -102,6 +151,7 @@ import {
102
151
  AuthService,
103
152
  EventBus,
104
153
  Logger,
154
+ BearerTokenInterceptor,
105
155
 
106
156
  // Helper Functions
107
157
  configureAuth0,
@@ -134,6 +184,7 @@ import {
134
184
  LogAttributes,
135
185
  LogSeverity,
136
186
  LoggerOptions,
187
+ BearerTokenInterceptorConfig,
137
188
  } from '@opensourcekd/ng-common-libs';
138
189
  ```
139
190
 
package/dist/index.cjs CHANGED
@@ -1073,9 +1073,321 @@ class Logger {
1073
1073
  }
1074
1074
  }
1075
1075
 
1076
+ /**
1077
+ * BearerTokenInterceptor — HTTP interceptor for bearer token injection.
1078
+ * Patches both `window.fetch` and `XMLHttpRequest` (for Angular's default
1079
+ * HttpClient backend), pure TypeScript, framework-agnostic, truly singleton
1080
+ * per identifier.
1081
+ */
1082
+ /**
1083
+ * BearerTokenInterceptor
1084
+ *
1085
+ * A per-identifier singleton that patches the global `window.fetch` to
1086
+ * automatically attach an `Authorization: Bearer <token>` header to every
1087
+ * request whose URL begins with the configured API base URL.
1088
+ *
1089
+ * **Singleton behaviour**: the first call to {@link BearerTokenInterceptor.getInstance}
1090
+ * for a given `id` creates the instance; subsequent calls with the same `id`
1091
+ * return the previously created instance, regardless of the `config` argument.
1092
+ *
1093
+ * Designed for Module Federation micro-frontend environments where both the
1094
+ * fetch wrapper and the XHR prototype must be shared across the shell and all
1095
+ * remote applications.
1096
+ *
1097
+ * @example
1098
+ * ```typescript
1099
+ * import { BearerTokenInterceptor, APP_CONFIG } from '@opensourcekd/ng-common-libs';
1100
+ *
1101
+ * // In the shell app — creates the instance
1102
+ * const interceptor = BearerTokenInterceptor.getInstance('shell', {
1103
+ * apiUrl: APP_CONFIG.apiUrl,
1104
+ * getToken: () => authService.getTokenSync(),
1105
+ * });
1106
+ * interceptor.activate();
1107
+ *
1108
+ * // In any MFE — same instance is returned
1109
+ * const same = BearerTokenInterceptor.getInstance('shell', { getToken: () => null });
1110
+ * console.log(same === interceptor); // true
1111
+ * ```
1112
+ */
1113
+ class BearerTokenInterceptor {
1114
+ static instances = new Map();
1115
+ static originalFetch = null;
1116
+ static originalXhrOpen = null;
1117
+ static originalXhrSend = null;
1118
+ id;
1119
+ apiUrl;
1120
+ tokenFn;
1121
+ active = false;
1122
+ constructor(id, config) {
1123
+ this.id = id;
1124
+ this.apiUrl = config.apiUrl ?? APP_CONFIG.apiUrl;
1125
+ this.tokenFn = config.getToken;
1126
+ }
1127
+ /**
1128
+ * Get or create the singleton interceptor for the given identifier.
1129
+ *
1130
+ * The first invocation with a given `id` creates the instance using the
1131
+ * supplied `config`. Subsequent calls with the same `id` return the existing
1132
+ * instance — the `config` argument is ignored on subsequent calls.
1133
+ *
1134
+ * @param id - Unique identifier for this interceptor (e.g. `'shell'`, `'mfe-orders'`)
1135
+ * @param config - Configuration used only when creating a new instance
1136
+ * @returns The singleton {@link BearerTokenInterceptor} for the given `id`
1137
+ *
1138
+ * @example
1139
+ * ```typescript
1140
+ * const interceptor = BearerTokenInterceptor.getInstance('shell', {
1141
+ * apiUrl: 'https://api.example.com',
1142
+ * getToken: () => authService.getTokenSync(),
1143
+ * });
1144
+ * ```
1145
+ */
1146
+ static getInstance(id, config) {
1147
+ if (BearerTokenInterceptor.instances.has(id)) {
1148
+ return BearerTokenInterceptor.instances.get(id);
1149
+ }
1150
+ const instance = new BearerTokenInterceptor(id, config);
1151
+ BearerTokenInterceptor.instances.set(id, instance);
1152
+ return instance;
1153
+ }
1154
+ /**
1155
+ * Get the identifier of this interceptor instance.
1156
+ * @returns The `id` string supplied when the instance was created
1157
+ */
1158
+ getId() {
1159
+ return this.id;
1160
+ }
1161
+ /**
1162
+ * Get the API base URL that this interceptor matches against.
1163
+ * @returns The configured API URL string
1164
+ */
1165
+ getApiUrl() {
1166
+ return this.apiUrl;
1167
+ }
1168
+ /**
1169
+ * Check whether this interceptor is currently active.
1170
+ * @returns `true` if the interceptor has been activated and not yet deactivated
1171
+ */
1172
+ isActive() {
1173
+ return this.active;
1174
+ }
1175
+ /**
1176
+ * Activate the interceptor.
1177
+ *
1178
+ * Patches `window.fetch` and `XMLHttpRequest` once (on the first active
1179
+ * interceptor) so that matching requests receive the bearer token.
1180
+ * Subsequent calls are no-ops. No-op in non-browser environments.
1181
+ */
1182
+ activate() {
1183
+ if (this.active || typeof window === 'undefined')
1184
+ return;
1185
+ this.active = true;
1186
+ BearerTokenInterceptor.patchFetch();
1187
+ BearerTokenInterceptor.patchXhr();
1188
+ }
1189
+ /**
1190
+ * Deactivate the interceptor.
1191
+ *
1192
+ * Stops attaching the bearer token for this interceptor's URL pattern.
1193
+ * Both the global fetch wrapper and the XHR prototype patches are removed
1194
+ * automatically once all registered interceptors have been deactivated.
1195
+ * No-op when already inactive.
1196
+ */
1197
+ deactivate() {
1198
+ if (!this.active)
1199
+ return;
1200
+ this.active = false;
1201
+ if (typeof window !== 'undefined') {
1202
+ BearerTokenInterceptor.maybeRestoreFetch();
1203
+ BearerTokenInterceptor.maybeRestoreXhr();
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Return the effective bearer token for a given request URL, or `null` if
1208
+ * this interceptor should not modify the request.
1209
+ *
1210
+ * Returns `null` when the interceptor is inactive, the URL does not begin
1211
+ * with the configured API base URL, or the token getter returns `null`.
1212
+ *
1213
+ * @param url - The absolute URL of the outgoing request
1214
+ * @returns Bearer token string or `null`
1215
+ */
1216
+ getEffectiveToken(url) {
1217
+ if (!this.active || !url.startsWith(this.apiUrl))
1218
+ return null;
1219
+ return this.tokenFn();
1220
+ }
1221
+ /**
1222
+ * Extract headers from a `RequestInit` object into a plain key-value map.
1223
+ * Handles `Headers` instances, `[key, value]` arrays, and plain objects.
1224
+ *
1225
+ * @param init - Optional `RequestInit` whose headers to extract
1226
+ * @returns A plain `Record<string, string>` copy of the headers
1227
+ */
1228
+ static extractHeaders(init) {
1229
+ if (!init?.headers)
1230
+ return {};
1231
+ if (init.headers instanceof Headers) {
1232
+ const h = {};
1233
+ init.headers.forEach((v, k) => { h[k] = v; });
1234
+ return h;
1235
+ }
1236
+ if (Array.isArray(init.headers)) {
1237
+ return Object.fromEntries(init.headers);
1238
+ }
1239
+ return { ...init.headers };
1240
+ }
1241
+ /**
1242
+ * Find the bearer token for a given URL by consulting all active interceptors.
1243
+ * Uses first-match semantics to avoid ambiguity when multiple interceptors
1244
+ * share the same API URL prefix.
1245
+ *
1246
+ * @param url - Absolute URL of the outgoing request
1247
+ * @returns The first matching bearer token string, or `null`
1248
+ */
1249
+ static findToken(url) {
1250
+ return ([...BearerTokenInterceptor.instances.values()]
1251
+ .map((i) => i.getEffectiveToken(url))
1252
+ .find((t) => t !== null) ?? null);
1253
+ }
1254
+ /**
1255
+ * Apply the `Authorization: Bearer` header from the first active interceptor
1256
+ * that matches the given URL. Uses first-match semantics to avoid ambiguity
1257
+ * when multiple interceptors share the same API URL prefix.
1258
+ *
1259
+ * @param url - Absolute URL of the outgoing fetch request
1260
+ * @param init - Original `RequestInit` options
1261
+ * @returns Modified `RequestInit` with the `Authorization` header added when applicable
1262
+ */
1263
+ static applyBearerToken(url, init) {
1264
+ const headers = BearerTokenInterceptor.extractHeaders(init);
1265
+ const token = BearerTokenInterceptor.findToken(url);
1266
+ if (token) {
1267
+ headers['Authorization'] = `Bearer ${token}`;
1268
+ }
1269
+ return { ...init, headers };
1270
+ }
1271
+ /**
1272
+ * Install the global fetch wrapper exactly once.
1273
+ * The wrapper delegates to {@link applyBearerToken} for every outgoing request.
1274
+ * No-op when already patched or in a non-browser environment.
1275
+ */
1276
+ static patchFetch() {
1277
+ if (BearerTokenInterceptor.originalFetch !== null || typeof window === 'undefined')
1278
+ return;
1279
+ // Store without bind so the original reference is preserved for identity checks on restore.
1280
+ BearerTokenInterceptor.originalFetch = window.fetch;
1281
+ window.fetch = (input, init) => {
1282
+ // Extract the URL string from all three possible input types:
1283
+ // • Request-like objects (e.g. the Fetch API Request) have a `.url` string property.
1284
+ // • URL objects do NOT have `.url`; String(urlObj) yields the full href (e.g. "https://…").
1285
+ // • Plain strings pass through String() unchanged.
1286
+ const hasUrl = typeof input.url === 'string';
1287
+ const url = hasUrl ? input.url : String(input);
1288
+ return BearerTokenInterceptor.originalFetch(input, BearerTokenInterceptor.applyBearerToken(url, init));
1289
+ };
1290
+ }
1291
+ /**
1292
+ * Restore the original `window.fetch` if no interceptors remain active.
1293
+ * No-op when the fetch has not been patched or some interceptors are still active.
1294
+ */
1295
+ static maybeRestoreFetch() {
1296
+ if (BearerTokenInterceptor.originalFetch === null)
1297
+ return;
1298
+ const anyActive = [...BearerTokenInterceptor.instances.values()].some((i) => i.active);
1299
+ if (anyActive)
1300
+ return;
1301
+ window.fetch = BearerTokenInterceptor.originalFetch;
1302
+ BearerTokenInterceptor.originalFetch = null;
1303
+ }
1304
+ /**
1305
+ * Install a global `XMLHttpRequest` prototype patch exactly once.
1306
+ *
1307
+ * Overrides `XMLHttpRequest.prototype.open` to capture the request URL on
1308
+ * the XHR instance, and `XMLHttpRequest.prototype.send` to inject the
1309
+ * `Authorization: Bearer` header (via `setRequestHeader`) before dispatching
1310
+ * the request. Supports Angular's default XHR-based `HttpClient` backend.
1311
+ *
1312
+ * No-op when already patched or in a non-browser environment.
1313
+ */
1314
+ static patchXhr() {
1315
+ if (BearerTokenInterceptor.originalXhrOpen !== null ||
1316
+ typeof window === 'undefined' ||
1317
+ typeof XMLHttpRequest === 'undefined')
1318
+ return;
1319
+ const originalOpen = XMLHttpRequest.prototype.open;
1320
+ const originalSend = XMLHttpRequest.prototype.send;
1321
+ BearerTokenInterceptor.originalXhrOpen = originalOpen;
1322
+ BearerTokenInterceptor.originalXhrSend = originalSend;
1323
+ // Capture the request URL so send() can match it against registered interceptors.
1324
+ // The `async` parameter is made optional to faithfully match both XHR `open()` overloads.
1325
+ XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
1326
+ const urlStr = url instanceof URL ? url.href : url;
1327
+ try {
1328
+ this._interceptorUrl = new URL(urlStr, window.location.href).href;
1329
+ }
1330
+ catch {
1331
+ this._interceptorUrl = urlStr;
1332
+ }
1333
+ // `async` defaults to true per the XHR spec when not supplied by the caller.
1334
+ originalOpen.call(this, method, url, async ?? true, username, password);
1335
+ };
1336
+ // Inject the Authorization header before dispatching the request.
1337
+ XMLHttpRequest.prototype.send = function (body) {
1338
+ const url = this._interceptorUrl;
1339
+ if (url) {
1340
+ const token = BearerTokenInterceptor.findToken(url);
1341
+ if (token) {
1342
+ this.setRequestHeader('Authorization', `Bearer ${token}`);
1343
+ }
1344
+ }
1345
+ originalSend.call(this, body);
1346
+ };
1347
+ }
1348
+ /**
1349
+ * Restore `XMLHttpRequest.prototype.open` and `.send` to their original
1350
+ * values if no interceptors remain active.
1351
+ * No-op when XHR has not been patched or some interceptors are still active.
1352
+ */
1353
+ static maybeRestoreXhr() {
1354
+ if (BearerTokenInterceptor.originalXhrOpen === null)
1355
+ return;
1356
+ const anyActive = [...BearerTokenInterceptor.instances.values()].some((i) => i.active);
1357
+ if (anyActive)
1358
+ return;
1359
+ XMLHttpRequest.prototype.open = BearerTokenInterceptor.originalXhrOpen;
1360
+ XMLHttpRequest.prototype.send = BearerTokenInterceptor.originalXhrSend;
1361
+ BearerTokenInterceptor.originalXhrOpen = null;
1362
+ BearerTokenInterceptor.originalXhrSend = null;
1363
+ }
1364
+ /**
1365
+ * Remove all registered instances and restore `window.fetch` and
1366
+ * `XMLHttpRequest` prototype methods to their original values.
1367
+ * Intended for use in test teardown only.
1368
+ * @internal
1369
+ */
1370
+ static _reset() {
1371
+ if (typeof window !== 'undefined') {
1372
+ if (BearerTokenInterceptor.originalFetch !== null) {
1373
+ window.fetch = BearerTokenInterceptor.originalFetch;
1374
+ }
1375
+ if (BearerTokenInterceptor.originalXhrOpen !== null && typeof XMLHttpRequest !== 'undefined') {
1376
+ XMLHttpRequest.prototype.open = BearerTokenInterceptor.originalXhrOpen;
1377
+ XMLHttpRequest.prototype.send = BearerTokenInterceptor.originalXhrSend;
1378
+ }
1379
+ }
1380
+ BearerTokenInterceptor.originalFetch = null;
1381
+ BearerTokenInterceptor.originalXhrOpen = null;
1382
+ BearerTokenInterceptor.originalXhrSend = null;
1383
+ BearerTokenInterceptor.instances.clear();
1384
+ }
1385
+ }
1386
+
1076
1387
  exports.APP_CONFIG = APP_CONFIG;
1077
1388
  exports.AUTH0_CONFIG = AUTH0_CONFIG;
1078
1389
  exports.AuthService = AuthService;
1390
+ exports.BearerTokenInterceptor = BearerTokenInterceptor;
1079
1391
  exports.EventBus = EventBus;
1080
1392
  exports.Logger = Logger;
1081
1393
  exports.STANDARD_JWT_CLAIMS = STANDARD_JWT_CLAIMS;