@starterlib/core
v0.8.0
Published
Noyau applicatif du socle frontal starter : session, intercepteurs, erreurs, langue, inactivite. Singleton obligatoire.
Readme
@starterlib/core
Noyau du socle frontal starter : session, intercepteurs, catalogue d'erreurs, langue a l'execution, theme, connectivite, inactivite, navigation accessible, chargement des remotes, sonde de version et formulaires non enregistres.
Le contrat du manifeste de remote (types, conventions, compatibilite des
trains, validation, schema JSON) et la liste de partage du socle
(partageDuSocle) vivent dans le point d'entree @starterlib/core/federation,
sans dependance a Angular : voir docs/recettes/federation.md du depot.
Singleton obligatoire
Deux instances de ce paquet, ce sont deux sessions : la moitie de l'interface se
croit connectee, l'autre non. Dans un assemblage federe, il se declare
singleton: true, strictVersion: true, et seul l'hote appelle
provideStarterCore().
Installation
pnpm add @starterlib/core @starterlib/utilConfiguration — une fois, dans l'hote
import { provideStarterCore } from '@starterlib/core';
export const appConfig: ApplicationConfig = {
providers: [
provideStarterCore({
pageDeConnexion: '/auth/connexion',
baseDuBff: '/bff', // defaut
}),
],
};provideStarterCore() fournit le client HTTP avec les intercepteurs du socle.
⚠️ Un remote ne doit pas appeler provideHttpClient() : il poserait un
second client dans l'injecteur de sa route, ses requetes perdraient les
intercepteurs, donc l'en-tete anti-falsification — et chaque ecriture tomberait
en 403. Une panne qui ne se voit qu'a l'ecriture, jamais a la lecture.
Session
const session = inject(StSession);
await session.relire(); // au demarrage
session.etat(); // 'inconnue' | 'anonyme' | 'indisponible' | 'ouverte'
session.identite(); // Identite | null
session.ouverte(); // boolean
const resultat = await session.connecter({ email, motDePasse });
// 'ouverte' | 'second-facteur-requis' | 'mot-de-passe-a-changer' | 'refusee'Egalement : connecterParLienMagique(), connecterParPasskey(),
deconnecter({ partout }).
versLaConnexion(motif?) part en pleine page vers la page de connexion, avec
l'adresse de retour et, s'il est connu, le motif (expiree,
fermee-ailleurs, inactivite, mot-de-passe-reinitialise). Le socle pose
expiree (session perdue sur une requete, echeance absolue), inactivite, et
fermee-ailleurs quand un autre onglet annonce la fermeture ;
mot-de-passe-reinitialise appartient a l'hote. La page de connexion le lit par
motifDeConnexion(route.snapshot.queryParamMap) — une valeur inconnue vaut
null, jamais un texte affiche tel quel.
Une deconnexion dans un onglet ferme les autres : chacun vide sa session et
part en pleine page vers la connexion, motif fermee-ailleurs. L'onglet qui a
demande la deconnexion, lui, ne recoit pas son propre message et garde son
chemin — c'est a l'ecran qui appelle deconnecter() de decider ou il va.
expirationAbsolue() est non nul dans les cinq dernieres minutes avant
expireAuPlusTardA, rafraichi toutes les trente secondes : { a, dansMs }.
Rien ne prolonge une expiration absolue, donc pas de dialogue — une banniere
qui laisse le temps d'enregistrer. A l'echeance, la session est perdue et la
page de connexion s'affiche avec le motif expiree.
Quatre points que le contrat du BFF impose, et qui se paient cher si on les oublie :
indisponiblen'est pasanonyme. Un 503 du magasin de sessions ne veut pas dire deconnecte : router vers la connexion ferait perdre sa place a quelqu'un dont la session est intacte.emailetnomAffichepeuvent etre nuls sur une session parfaitement valide : ils sont lus chez le service d'identite a l'ouverture, et ne sont pas rattrapes ensuite. Tout affichage doit tenir sans eux.langueest toujours absente a ce jour : la preference se lit dans le profil.- Ne jamais scruter
GET /bff/session: l'appel prolonge l'inactivite cote serveur.
Erreurs
const erreur = versApiError(inconnue); // ApiError : code, status, traceId, erreurs[], reessayerDans
erreursParChamp(erreur); // pour un formulaire
estRejouable(erreur); // proposer « Reessayer », ou noncode est une chaine, pas une enumeration : la passerelle relaie verbatim
les codes des services qu'elle precede, et un type ferme ferait echouer le front
sur un code inconnu. Un code absent vaut ERREUR_INCONNUE, et l'ecran affiche
un message generique avec l'identifiant de trace — jamais une phrase inventee a
partir du statut.
Intercepteurs
Poses par provideStarterCore(), dans cet ordre :
| Intercepteur | Ce qu'il fait |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| Correlation | X-Correlation-Id en 32 hexadecimaux minuscules — toute autre forme est remplacee par la passerelle |
| Session perdue | 401 BFF_SESSION_REQUIRED ou PLATFORM_UNAUTHENTICATED : etat vide, onglets prevenus, connexion en pleine page |
| Anti-falsification | X-CSRF-Token sur les ecritures ; relit la session et rejoue une fois sur BFF_CSRF_REJECTED |
| Idempotence | Idempotency-Key, seulement si l'appelant en fournit une |
⚠️ Aucun de ces en-tetes ne sort de l'origine de l'application. Un jeton
anti-falsification envoye a un tiers est une fuite ; un identifiant de correlation
envoye a un stockage d'objets fait echouer le controle prealable CORS, donc le
depot. REQUETE_EXTERNE force ce comportement quand l'adresse ne suffit pas a le
deduire (un mandataire de meme origine vers un tiers).
Deux jetons de contexte pour les cas particuliers :
// Une clef d'idempotence appartient a l'INTENTION, pas a la requete : engendree
// une fois, reutilisee pour tous les rejeux.
const contexte = new HttpContext().set(CLE_D_IDEMPOTENCE, crypto.randomUUID());
// Sur la page de connexion, un 401 est une reponse normale : rediriger vers la
// connexion depuis la connexion boucle.
new HttpContext().set(SANS_REDIRECTION, true);Televersement de fichiers
private readonly televersement = inject(StTeleversement);
const envoi = this.televersement.televerser(fichier, { usage: 'JUSTIFICATIF' });
// envoi.etape() ; envoi.progression() ; envoi.annuler()
const depose = await envoi.termine; // null si refuse ou annule ; envoi.erreur() dit pourquoiTrois temps : declaration (avec cle d'idempotence), depot par PUT pre-signe
directement vers le stockage d'objets, puis confirmation — taille, type,
empreinte, antivirus.
- ⚠️ Un fichier n'existe qu'apres la confirmation : avant, l'analyse peut encore detruire l'objet.
- ⚠️ Rien du socle ne part vers le stockage : ni cookie, ni jeton, ni correlation.
- ⚠️ L'URL de lecture est courte (
urlDeLecture) et ne se met pas en cache. - ⚠️ Le depot exige
XMLHttpRequest, transport par defaut d'HttpClient:withFetch()ferait perdre la progression, sans autre symptome.
Cote infrastructure : origine du stockage dans connect-src, et PUT autorise
en CORS sur le compartiment. Voir
docs/recettes/politique-de-securite-du-contenu.md du depot.
Fonctionnalites optionnelles
Elles se passent a provideStarterCore(), apres la configuration :
provideStarterCore(
{ pageDeConnexion: '/auth/connexion' },
withLangue({
langues: ['fr', 'en'],
langueParDefaut: 'fr',
catalogue: (perimetre, langue) => `/ui/socle/i18n/${perimetre}.${langue}.json`,
}),
withNavigation({
titre: (route) => (route === undefined ? 'Espace client' : `${route} — Espace client`),
}),
withFederation({ chargeur: loadRemoteModule }),
withVersion({ url: '/bff/ui/version', periodeMs: 15 * 60_000 }),
);Langue a l'execution
const langue = inject(StLangue);
await langue.changer('en'); // toute l'interface bascule, sans rechargement
langue.locale(); // 'en-GB' : alimente ST_LOCALE de @starterlib/ui
// Dans un composant, liee a un perimetre :
protected readonly t = injecterTraduction('facturation');
// gabarit : {{ t('facture.titre', { numero }) }}Chaque remote apporte son perimetre par charger('facturation'), charge a la
demande : la coquille ne connait pas ses textes. Dans un assemblage federe, le
catalogue se charge AVANT les routes du remote — avecPerimetre autour du
loadChildren — et ses chemins, absolus et haches, viennent du manifeste :
declarerLesCatalogues('facturation', remote.i18n.catalogues) prime sur la
fonction catalogue de la configuration, qui reste le repli.
lang et dir sont poses sur <html> des le depart et a chaque bascule
(dir() est aussi un signal : rtl pour ar, he, fa, ur). Le titre de l'onglet,
lui, ne suit la langue que si la coquille appelle StNavigation.rafraichir()
depuis un effet sur courante().
- La bascule charge AVANT de changer. Dans l'autre ordre, toute la page montre ses cles brutes le temps d'un aller-retour reseau.
- Une cle absente affiche la cle, et non un texte de remplacement : un trou silencieux passe la relecture, un trou visible non.
- Le pluriel passe par
Intl.PluralRules— le francais met zero au singulier, l'anglais au pluriel, et le polonais a trois formes.
Navigation accessible
withNavigation() remplace la strategie de titre du routeur et repare les trois
choses qu'une application a page unique cesse de faire : le titre du document,
la position du focus, et l'annonce du changement de page.
Le focus va au h1 de la nouvelle page — tabindex="-1" est pose par le socle,
sans quoi focus() sur un titre ne fait rien du tout — sans faire defiler
(preventScroll), pour que la position restauree par le routeur reste la
sienne. Rien ne bouge au premier affichage ni sur un changement de parametre
seul : sinon un filtre lie a l'adresse arracherait le focus au champ en cours de
saisie. Une page sans titre de niveau 1 est annoncee a la place, et signalee en
developpement par un avertissement [st-navigation] : c'est un defaut de la
page, pas un mode de fonctionnement.
rafraichir() repose le titre du dernier changement de route sans toucher au
focus : la coquille l'appelle a la bascule de langue, parce que le titre d'une
route est une cle traduite que le routeur ne recalcule pas.
// Annoncer sans afficher, par la region vivante unique de l'application.
inject(StAnnonceur).annoncer('Virement enregistre');
inject(StAnnonceur).annoncer('Session expiree', 'assertif');⚠️ assertif INTERROMPT la lecture en cours : a reserver a ce qui ne peut pas
attendre. Pour une confirmation ordinaire, il fait perdre sa place a la personne
qui lit.
Gardes de route
{ path: 'profil', component: Profil, canActivate: [sessionOuverte] }
{ path: 'operations', component: Operations,
canActivate: [sessionOuverte, motDePasseAJour('/compte/mot-de-passe')] }⚠️ Un confort de navigation, jamais une decision d'autorisation. Tout ce
qu'elles lisent vient du navigateur, donc de quelque chose que l'on peut
modifier. Le serveur decide, et lui seul. sessionOuverte laisse passer sur
indisponible : un 503 du magasin de sessions n'est pas une deconnexion.
{ path: 'profil', component: Profil, canDeactivate: [formulaireNonEnregistre] }formulaireNonEnregistre demande confirmation avant de quitter un formulaire
modifie : quand le composant repond formulaireModifie() vrai, ou quand le
registre StFormulaires porte une modification (marquerModifie(id),
marquerPropre(id), aDesModifications()). Le registre pose aussi le
beforeunload du navigateur, tant qu'il y a quelque chose a perdre — et
seulement alors. La question passe par CONFIRMATION_DE_SORTIE, une fonction
() => Promise<boolean> ; sans fournisseur, le confirm() du navigateur la
pose.
// Dans l'hote : le dialogue du socle, SANS import statique de `ui/surfaces`.
{
provide: CONFIRMATION_DE_SORTIE,
useFactory: () => confirmationParDialogue(() => import('@starterlib/ui/surfaces')),
}⚠️ L'import reste dynamique, et c'est tout l'objet de la facade. Injecter
StDialog dans une fabrique oblige a l'importer statiquement : un seul symbole
faisait alors entrer 67,4 Ko au demarrage de la coquille — le point d'entree,
@angular/cdk entier et un morceau d'@angular/aria — pour un dialogue qui ne
peut pas s'afficher au premier ecran (ADR 0031, decision 3). Le specificateur
etant dans la carte d'importation, l'import dynamique resout vers l'instance
PARTAGEE : l'identite d'instance est preservee.
confirmationParDialogue() s'appelle dans un contexte d'injection, et y capture
l'injecteur, la langue et le document avant toute attente : passe le premier
await, le contexte n'existe plus et inject() leverait. Le socle paie ce
piege une fois, pour tous. Le texte vient du catalogue du perimetre socle
(socle.sortie.titre, .message, .quitter, .rester) ; une cle absente
retombe sur le texte du repli, jamais sur la cle brute a l'ecran. Un morceau qui
n'arrive pas — reseau coupe, cache vide, livraison en cours — repose la question
par confirm() : laisser partir sans rien demander detruirait la saisie en
silence. Une fabrique de demande passee en second argument l'emporte sur tout,
et elle est relue a chaque question.
Federation : la liste de partage
// federation.config.mjs — coquille ET remotes, la MEME ligne.
import { partageDuSocle } from '@starterlib/core/federation';
shared: { ...share({ ...ANGULAR, ...partageDuSocle('0.8') }) }partageDuSocle(train) rend tous les points d'entree publies du socle en
singleton: true, strictVersion: true, avec la plage du train : '0.8' devient
~0.8.0, '0.8.2' devient ~0.8.2, et une plage complete (~0.8.0, ^1.2.0)
passe telle quelle. Toute autre forme leve — 'auto' ne dit pas le train, et un
partage sans plage explicite ferait de chaque correctif du socle un conflit de
version, donc un redeploiement de toute la flotte.
⚠️ Un point d'entree oublie dans un federation.config.mjs ne casse rien a
la construction : le specificateur se duplique simplement dans le remote, avec
son etat. C'est ainsi que deux ST_LIBELLES ont coexiste, une moitie d'ecran
restant en francais apres une bascule en anglais. La liste vit donc a un seul
endroit, et une garde la compare aux points d'entree reellement publies
(ADR 0031, decision 1).
Le point d'entree est pur — ni Angular, ni Native Federation — et se lit depuis
Node : un .mjs de configuration l'importe directement. POINTS_D_ENTREE_DU_SOCLE
est exporte a cote, pour l'outillage qui a besoin de la liste nue.
Federation : charger les remotes
withFederation({ chargeur: loadRemoteModule }); // dans l'hote
{ path: route.prefixe, title: route.titre, data: { remote: remote.nom },
loadChildren: avecPerimetre(remote.nom, routesDeRemote(remote.nom, route.module)) }
provideRouter(routes, withNavigationErrorHandler(redirigerVersIndisponible()));chargerRemote(nom, module) appelle le chargeur de l'hote et nomme tout echec
(ErreurDeChargementDeRemote { remote, module, cause }) ; routesDeRemote
en fait un loadChildren qui exige routes ; avecPerimetre charge le
catalogue du perimetre avant de rendre les routes ; redirigerVersIndisponible
transforme l'echec en navigation vers /indisponible/<remote> avec l'adresse
visee dans state.retour, et laisse remonter toute autre erreur. Le detail est
dans docs/recettes/federation.md.
Nouvelle version servie
const version = inject(StVersion);
version.demarrer(); // sonde tout de suite, au retour de visibilite, toutes les 15 min
version.nouvelleVersion(); // vrai des que la coquille ou un remote a change
version.rechargerALaProchaineNavigation(); // faux tant qu'un formulaire est modifieJamais de rechargement automatique : le socle sait, la coquille montre, et le rechargement complet attend une navigation sans saisie en cours.
Ordre des bannieres
ORDRE_DES_BANNIERES — hors ligne, fin absolue de session, nouvelle version,
maintenance — et trierLesBannieres(actives), qui en garde deux au plus, dans
cet ordre : au-dela, la troisieme pousse le contenu hors de l'ecran.
Theme, connectivite, inactivite
inject(StTheme).choisir('sombre'); // 'systeme' | 'clair' | 'sombre'
inject(StConnectivite).enLigne();
inject(StInactivite).demarrer(); // apres l'ouverture de session- Le theme a trois etats, pas deux : une bascule clair/sombre oblige a choisir une fois pour toutes, et quelqu'un dont le systeme passe au sombre le soir se retrouve en clair.
- L'inactivite compte l'activite HUMAINE, et non les requetes : cote serveur, toute requete prolonge la session, et un ecran que personne ne regarde mais qui rafraichit un tableau resterait ouvert indefiniment.
- Pendant l'avertissement, un clic ne prolonge rien : il faut repondre, sinon le dialogue disparaitrait sous la souris sans que la session ait ete prolongee cote serveur.
Ce qui n'entre jamais ici
Aucun jeton d'authentification, et aucun stockage navigateur. Le BFF detient la session ; le navigateur ne detient qu'un cookie opaque qu'il ne peut pas lire.
