@ssi-lib/interceptor
v1.2.1
Published
Interceptor Axios modular: init único para headers, strip CORS, refresh 401 y retry. Desacoplado de React.
Readme
@ssi-lib/interceptor
Interceptor Axios modular: un initInterceptor por app cubre headers, strip
CORS, refresh en 401 y retry. Sin acoplarse a React (host, remotes, Node).
Modelo mental
initInterceptor({ axios, getAccessToken, headers: { 'x-tenant-id': fn, 'x-trace-id': fn }, refreshAccessToken, onAuthFailure })
│
├─ request ──▶ Bearer + headers (mapa llave → valor por request)
│
└─ response ──▶ 401 → refresh (single-flight) → retry 1 vez
2xx → onResponse?
otros errores → onResponseError (notifica, no traga)El Host no reimplementa interceptores: instala la lib, llama init con
providers de sesión, y axios.create() hereda lo mismo.
onResponseError sigue sin renderizar UI: solo notifica. El 401 con refresh
configurado no dispara ese callback (lo maneja onAuthFailure si el refresh falla).
Requisitos
- Instalar el paquete:
# registry
npm i @ssi-lib/interceptor
# tarball local
npm run pack:local
npm i ./ssi-lib-interceptor-<ver>.tgz- Tener
axios(>=0.21.0 || ^1.0.0) como peer dependency. - Usar una instancia de Axios (
axios.create(...)o la global) — ocreateApiClientsi preferís factory.
1. Host / app — initInterceptor (recomendado)
Un init en bootstrap, antes de cargar remotes y antes de axios.create()
en facades. La lib adjunta el default axios (remotes export const api = axios)
y envuelve axios.create().
import axios from 'axios';
import { initInterceptor } from '@ssi-lib/interceptor';
initInterceptor({
axios,
getAccessToken: () => readPersistedAuthToken(),
headers: {
'x-tenant-id': () => readSessionTenantContext().tenantId,
'x-trace-id': () => peekOrderId() ?? Date.now(),
},
refreshAccessToken: () => refreshPersistedSession(), // fetch al IdP, no axios
onAuthFailure: () => forceLogoutToLogin(),
});Después de eso:
- Host facades:
axios.create({ baseURL })— sinattachInterceptor - Remotes:
export const api = axios— sin interceptores ni headers de auth - 401 → un refresh compartido → retry una vez → si falla,
onAuthFailure
refreshAccessToken debe usar fetch (u otro cliente), no la instancia axios
interceptada.
Valores de header extra: literal, null/undefined (no se envía), o función
sync/async. Para un cliente suelto sin init, sigue existiendo attachInterceptor.
2. Manejo centralizado de errores (onResponseError)
import axios from 'axios';
import { attachInterceptor, type ErrorContext } from '@ssi-lib/interceptor';
import { openGlobalErrorDialog } from './ui/dialogService';
export const api = axios.create({ baseURL: 'https://api.tuempresa.com' });
attachInterceptor(api, {
headers: {
'client-id': 'portal-mfe',
},
onResponseError: (_error, context: ErrorContext) => {
console.error(
`[API Error] ${context.method} ${context.url} (${context.status}):`,
context.message
);
if (context.isNetworkError) {
openGlobalErrorDialog({
title: 'Error de Conexión',
description: 'No pudimos conectar con los servidores.',
});
return;
}
if (context.isTimeout) {
openGlobalErrorDialog({
title: 'Tiempo de Espera Agotado',
description: 'La solicitud tardó demasiado. Intentá de nuevo.',
});
return;
}
if (context.isCanceled) {
return; // abort / CancelToken — sin UI
}
openGlobalErrorDialog({
title: `Error (${context.status ?? 'Desconocido'})`,
description: context.message,
});
},
errorConfig: {
// No abrir diálogo si un 404 es esperado por el flujo
excludeStatusCodes: [404],
// Endpoints silenciosos / telemetría
excludeUrls: ['/api/telemetry', /\/health\/.*/],
// Parser custom del payload del backend
extractErrorMessage: (axiosError) => {
const data = axiosError.response?.data as { customError?: string };
return data?.customError || 'Ocurrió un error inesperado';
},
},
});El error de Axios siempre se re-rechaza (Promise.reject): el callback no lo
traga; solo notifica.
3. Filtros de URL en request (includeUrls / excludeUrls)
Solo inyectá headers en rutas que te interesan:
attachInterceptor(api, {
headers: {
Authorization: async () => `Bearer ${await getAuthToken()}`,
},
// Solo estas URLs reciben headers
includeUrls: ['/api/', /\/v1\/orders/],
// Nunca en login / APIs de terceros
excludeUrls: ['/auth/login', 'https://cdn.terceros.com'],
});excludeUrls gana sobre includeUrls cuando ambas coinciden.
4. Habilitar / deshabilitar dinámicamente (enabled)
attachInterceptor(api, {
headers: { 'x-tenant-id': () => getTenantId() },
enabled: (config) => config.method?.toUpperCase() !== 'OPTIONS',
// o: enabled: false — apaga el request interceptor
});5. Respuestas exitosas (onResponse)
Útil para logging, métricas o telemetría:
attachInterceptor(api, {
onResponse: (response) => {
console.log(
`[HTTP 2xx] ${response.config.method?.toUpperCase()} ${response.config.url} → ${response.status}`
);
},
});6. Factory: cliente Axios preconfigurado (createApiClient)
import { createApiClient } from '@ssi-lib/interceptor';
export const apiClient = createApiClient({
axiosConfig: {
baseURL: 'https://api.tuempresa.com/v1',
timeout: 15000,
},
interceptorConfig: {
headers: {
'x-tenant-id': 'tenant-acme',
'client-id': 'portal-proveedores',
},
onResponseError: (_error, context) => {
console.error(context.message, context);
},
},
});
// Uso normal de Axios
await apiClient.get('/orders');7. Helpers de headers
import {
attachInterceptor,
createBearerAuthHeader,
createClientIdHeader,
createCorrelationIdHeader,
createTenantHeader,
} from '@ssi-lib/interceptor';
attachInterceptor(api, {
headers: {
...createTenantHeader(() => getActiveTenantId()), // x-tenant-id
...createClientIdHeader('bmad-customer-mfe'), // client-id
...createBearerAuthHeader(async () => await getSessionToken()), // Authorization: Bearer …
...createCorrelationIdHeader(), // x-correlation-id (UUID por request)
},
});Cada helper acepta un segundo argumento para renombrar el header:
createTenantHeader(getTenantId, 'X-Org-Id');
createBearerAuthHeader(getToken, 'X-Access-Token');
createCorrelationIdHeader(() => myTraceId(), 'X-Request-Id');createBearerAuthHeader antepone Bearer si el token no lo trae.
8. Headers factory (mapa completo por request)
En lugar de un diccionario fijo, una función que arma todos los headers:
attachInterceptor(api, {
headers: async (config) => ({
'client-id': 'portal-mfe',
'x-tenant-id': getTenantId(),
Authorization: config.url?.includes('/public')
? undefined
: `Bearer ${await getAuthToken()}`,
}),
});9. Error al resolver headers (onError)
Si falla la resolución de un header (provider que lanza), podés capturarlo sin romper el wiring del interceptor de response:
attachInterceptor(api, {
headers: {
Authorization: async () => {
throw new Error('token store unavailable');
},
},
onError: (error, config) => {
console.error('[headers] falló resolución', config.url, error);
},
});La petición se rechaza igual; onError solo notifica.
10. Desconexión (eject)
const handle = attachInterceptor(api, {
headers: { 'client-id': 'portal-mfe' },
onResponseError: (_e, ctx) => console.error(ctx.message),
});
// Quita request + response interceptors
handle.eject();Alias de compatibilidad: attachHeaderInterceptor ≡ attachInterceptor.
11. Completo — host MFE típico
import axios from 'axios';
import { initInterceptor, type ErrorContext } from '@ssi-lib/interceptor';
initInterceptor({
axios,
getAccessToken: () => sessionStorage.getItem('access_token'),
getTenantId: () => sessionStorage.getItem('tenant_id'),
refreshAccessToken: () => refreshSession(),
onAuthFailure: () => {
sessionStorage.clear();
window.location.replace('/login');
},
stripHeaders: ['language', 'tenantId', 'tenantid'],
excludeUrls: ['/auth/login', '/auth/refresh', /\/health/],
onResponseError: (_error, context: ErrorContext) => {
if (context.isCanceled) return;
window.dispatchEvent(
new CustomEvent('app:api-error', { detail: context })
);
},
errorConfig: {
excludeUrls: [/\/telemetry/],
},
});API
Exports
| Export | Rol |
|--------|-----|
| initInterceptor(options) | Init único: default axios + create(), headers, strip, 401 retry |
| attachInterceptor(instance, config) | Conecta request + response a una instancia |
| attachHeaderInterceptor | Alias de attachInterceptor |
| createApiClient(options) | axios.create + attachInterceptor |
| createTenantHeader / createClientIdHeader / createBearerAuthHeader / createCorrelationIdHeader / createTraceIdHeader | Helpers de headers |
| resolveHeaders / matchUrl / shouldInterceptUrl | Utilidades de request |
| buildErrorContext / shouldHandleResponseError / extractDefaultErrorMessage | Utilidades de error |
| isAxiosNetworkError / isAxiosTimeout / isAxiosCancel | Detectores |
| Tipos | InterceptorConfig, ErrorContext, InterceptorHandle, … |
InterceptorConfig
| Campo | Tipo | Descripción |
|-------|------|-------------|
| headers? | HeaderDictionary \| HeadersFactory | Headers estáticos/dinámicos a inyectar |
| includeUrls? | UrlPattern[] | Solo estas URLs reciben headers |
| excludeUrls? | UrlPattern[] | Estas URLs no reciben headers |
| enabled? | boolean \| (config) => boolean \| Promise<boolean> | Apaga el request interceptor (default true) |
| onError? | (error, config) => void | Fallo al resolver headers |
| onResponse? | (response) => void \| Promise<void> | Respuestas 2xx |
| onResponseError? | (error, context) => void \| Promise<void> | Errores HTTP / red / timeout |
| errorConfig? | ErrorInterceptorConfig | Filtros y parser de mensaje |
| stripHeaders? | string[] | Headers a borrar antes de enviar |
| refreshAccessToken? | () => token \| Promise<token> | Refresh + retry en 401 (single-flight) |
| onAuthFailure? | () => void | Refresh falló o no hay token |
| unauthorizedStatus? | number | Status que dispara refresh (default 401) |
ErrorInterceptorConfig
| Campo | Descripción |
|-------|-------------|
| excludeStatusCodes? | Códigos que no disparan onResponseError |
| excludeUrls? | URLs/patrones que no disparan onResponseError |
| extractErrorMessage? | Parser custom del mensaje desde AxiosError |
ErrorContext
| Propiedad | Tipo | Descripción |
|-----------|------|-------------|
| status | number \| undefined | HTTP status (undefined si falló la red) |
| statusText | string \| undefined | Texto del status |
| url | string \| undefined | URL de la petición |
| method | string \| undefined | Método en mayúsculas |
| message | string | Mensaje normalizado |
| data | unknown | response.data del error |
| isNetworkError | boolean | Fallo de conectividad |
| isTimeout | boolean | Timeout superado |
| isCanceled | boolean | Abort / CancelToken |
| config | InternalAxiosRequestConfig | Config de Axios |
InterceptorHandle
| Campo / método | Descripción |
|----------------|-------------|
| eject() | Remueve interceptors de request y response |
| instance | Instancia de Axios usada |
| requestInterceptorId / responseInterceptorId | IDs internos de Axios |
| interceptorId | Compat: request id o response id |
CreateApiClientOptions
| Campo | Descripción |
|-------|-------------|
| axiosConfig? | CreateAxiosDefaults (baseURL, timeout, …) |
| interceptorConfig | InterceptorConfig (requerido) |
Build
npm install
npm run build # dist/ (ESM + CJS + .d.ts)
npm run typecheck
npm test
npm run pack:local # ssi-lib-interceptor-<ver>.tgz