npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/util

Configuration — 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 :

  • indisponible n'est pas anonyme. 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.
  • email et nomAffiche peuvent 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.
  • langue est 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 non

code 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 pourquoi

Trois 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 modifie

Jamais 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.