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

@astratra/client

v0.3.0

Published

Framework-agnostic client-side plumbing: refresh-on-401 with single-flight renewal, a public-list route guard, per-condition password rules shared with the server, and an offline mutation queue that keeps order.

Readme

@astratra/client

La plomberie côté client, faite une fois : session, garde de route, règles de mot de passe, file hors ligne.

Rien de spécifique à un framework — c'est de la logique pure, testable sans navigateur. Pour les liaisons React (contextes, hooks, RequireAuth), voir @astratra/react.

Aucune dépendance à l'exécution.


Le tableau de bord, déclaré en données

Un tableau de bord qui code ses tuiles en dur sert exactement un rôle. Déclaré en données — id, chemin, rôles —, il en sert autant que le produit en a, et ajouter un écran devient ajouter une ligne.

const catalog = createToolCatalog([
  { id: 'orders', path: '/orders', roles: ['owner', 'seller'], endpoint: '/orders' },
  { id: 'billing', path: '/billing', roles: ['owner'] }
]);

catalog.forRole('seller');            // ce que ce rôle voit, dans l'ordre déclaré
catalog.needsContext(billing);        // true : pas d'endpoint, il faut choisir d'abord

L'accès se déclare par outil, et se refuse par défaut. Un rôle absent de la liste — y compris l'absence de rôle, ce à quoi ressemble une session à moitié restaurée — est refusé. Lister qui est interdit admet en silence chaque rôle ajouté plus tard.

Ce n'est pas une frontière de sécurité. Cela décide ce qu'un écran propose ; le serveur décide ce qu'il accorde. Cacher une tuile interdite évite à quelqu'un un écran qui ne ferait que lui répondre en erreur ; ça n'arrête personne de déterminé.

Lire une charge utile qu'on n'a pas dessinée

Un écran générique — « montre-moi ce qu'il y a derrière cet outil » — fait face à un point d'entrée qui répond comme il veut : un tableau nu, un tableau enveloppé, ou un objet seul pour une route de détail.

readResourceItems({ orders: [...] });   // le tableau, où qu'il soit
readResourceTitle(row);                 // fullName › name › title › … › id

Les replis sont ordonnés par ce qui identifie le mieux une ligne pour un humain : un nom complet vaut mieux qu'un e-mail, un e-mail vaut mieux qu'un identifiant. L'identifiant est le dernier recours — « 68f3c1a » ne dit rien à personne.

Réglages groupés, et où l'on atterrit

const menu = createSettingsMenu({
  groups: ['account', 'shop', 'support'],
  sectionGroups: { profile: 'account', billing: 'shop' }
});

const home = createHomeRoutes({ routes: { owner: '/dashboard' }, fallback: '/home' });

L'ordre est celui déclaré, pas celui dans lequel les sections arrivent : un écran de réglages qui se réorganise entre deux chargements désoriente.

Une section inconnue est quand même affichée. Jeter ce que la table ignore fait disparaître de l'application, sans erreur nulle part, toute section ajoutée ensuite. Mal rangée est un défaut cosmétique ; invisible est une fonctionnalité manquante.

Le repli d'accueil doit rester une page qu'un rôle inconnu a le droit de voir. Sans cette table, tout le monde retombait sur la page publique de pré-connexion — et se faisait renvoyer vers la connexion une seconde après s'être authentifié. Pointer le repli vers un tableau de bord du personnel transformerait un oubli de correspondance en fuite d'accès.

Session

Rester connecté sans que l'utilisateur le remarque.

Les jetons d'accès expirent exprès — leur courte durée de vie est ce qui limite les dégâts d'un vol. Le prix : chaque client doit gérer l'instant de l'expiration — attraper le 401, renouveler la session, rejouer la requête, sans qu'un écran de connexion surgisse au milieu d'une sauvegarde.

Chaque client réécrit cette logique, et les trois mêmes bugs reviennent.

Les trois bugs

La ruée sur le renouvellement. Cinq requêtes échouent ensemble quand le jeton meurt ; cinq renouvellements partent en course, et quatre consomment un jeton de rafraîchissement tourné qui n'est plus valable — déconnectant l'utilisateur au moment précis où tout était rattrapable. Le renouvellement est à vol unique : un appel, tous les autres l'attendent.

La boucle infinie. Le point de renouvellement répond lui-même 401, le client tente de renouveler, qui répond 401… Les chemins qui ne doivent jamais déclencher un renouvellement sont exclus par nom — /auth/refresh et /auth/login en tête. Un mot de passe faux est un mot de passe faux, pas une session expirée.

Le rejeu de la mauvaise chose. Une requête est rejouée une fois après un renouvellement réussi. Un second 401 avec une session fraîche est une vraie réponse — une permission refusée, pas un problème de jeton.

Mise en place de la session

const { createSessionClient, SessionExpiredError } = require('@astratra/client');

const session = createSessionClient({
  // Ton transport : axios, fetch, ce que tu veux. Doit rejeter avec un
  // `status` sur échec HTTP.
  request: (path, init) => api(path, init),

  // Ton renouvellement : poste le refresh token, met le stockage à jour.
  refresh: async () => {
    const { token } = await api('/auth/refresh', { method: 'POST' });
    await storage.setToken(token);
  },

  excluded: ['/auth/refresh', '/auth/login'],

  // Une seule fois par expiration définitive : vider le stockage, router
  // vers l'écran de connexion.
  onSessionExpired: () => router.replace('/login'),
});

const eleves = await session.call('/students');

Transport et stockage sont injectés : axios ou fetch, cookie ou SecureStore, web ou mobile — même logique. C'est le point : cette mécanique existait en deux exemplaires, une par plateforme, avec les mêmes pièges résolus deux fois.

L'expiration définitive surface en SessionExpiredError typée, avec la cause d'origine conservée — l'interface peut router dessus sans analyser un message.

Le garde de route

Le bug d'origine : la déconnexion remplaçait l'écran du dessus par la page de connexion — toute la pile de navigation restait derrière. Quelques appuis sur « retour » ramenaient sur le tableau de bord, déconnecté mais avec les données encore à l'écran.

const { createRouteGuard } = require('@astratra/client');

const guard = createRouteGuard({
  publicSegments: ['home', 'login', 'forgot-password', 'reset-password'],
});

// Dans ton layout de navigation :
if (guard.shouldRedirectToLogin({ isLoading, isAuthenticated, route: segments })) {
  router.replace(guard.loginRoute);
}

Deux décisions portent la protection :

La liste nomme ce qui est PUBLIC, tout le reste est fermé. Une liste d'écrans protégés se lit plus naturellement et échoue en silence : chaque écran ajouté ensuite et oublié dans la liste part ouvert en production.

Tant que la session se restaure, rien n'est décidé. Au démarrage, « connecté » est faux avant d'être vrai ; rediriger sur cet état transitoire éjecte un utilisateur parfaitement connecté à chaque démarrage à froid.

Les règles de mot de passe

La forme habituelle : une expression régulière, recopiée dans l'écran d'inscription et appliquée à l'aveugle par le serveur. L'utilisateur voit une ligne — « 8 caractères min… » — et ne découvre qu'au rejet LAQUELLE des cinq conditions manquait. Pire : les deux copies dérivent.

const { createPasswordRules } = require('@astratra/client');

const rules = createPasswordRules();       // 8+, minuscule, majuscule, chiffre, spécial

rules.check('Abc1');
// [{ key: 'length', met: false }, { key: 'lowercase', met: true }, …]
// → l'écran coche les conditions pendant la frappe

rules.strength('Abc1');                    // 0.8 — la barre de progression
rules.canSubmit(password, confirmation);   // la condition du bouton

Chaque condition porte une clé de traduction, pas une phrase : la règle ne transporte aucune langue. Et le module n'a aucune dépendance pour une raison précise : le même code tourne à l'écran et sur le serveur — les deux bouts ne peuvent plus se contredire.


Hors ligne

Du travail qui survit à la perte du réseau.

Une application utilisée sur le terrain — une salle de classe sans signal, une boutique au wifi capricieux — ne peut pas traiter « hors ligne » comme une erreur. Une modification faite hors ligne est enregistrée au lieu d'échouer : elle rejoint une file, et au retour du réseau la file se rejoue, dans l'ordre.

Le stockage est injecté : IndexedDB sur le web, SQLite ou AsyncStorage sur mobile, mémoire dans les tests. Même logique partout.

Les règles qui rendent une file digne de confiance

L'ordre est gardé. « Marquer présent » puis « marquer absent » doivent atterrir dans cet ordre, sinon le registre finit par dire le contraire de ce que la personne a fait.

Une panne arrête le rejeu, elle ne saute pas. Sauter une action en échec et appliquer celles de derrière rejoue l'histoire dans le désordre — précisément ce que la file existe pour empêcher. La file s'arrête, rend compte, et reprend au même endroit la prochaine fois.

Sauf quand le serveur dit non. Un refus (un 4xx) est une réponse : l'action est fausse, et garder toute la file en otage derrière elle bloquerait pour toujours tout ce qui suit. Les actions refusées sont mises de côté — visiblement : l'utilisateur doit apprendre qu'un travail qu'il croit sauvegardé ne l'est pas.

Mise en place de la file

const { createOfflineQueue } = require('@astratra/client');

const queue = createOfflineQueue({
  store: indexedDbStore,             // ou SQLite, ou AsyncStorage — createMemoryQueueStore() pour les tests
  handlers: {
    mark_attendance: (payload) => api.post('/attendance', payload),
    sign_assignment: (payload) => api.post('/assignments/sign', payload),
  },
  onRejected: (action, error) => toast.warn(`Refusé : ${error.message}`),
});

// Hors ligne : on enregistre au lieu d'échouer.
await queue.enqueue('mark_attendance', { student, state: 'present' });

// Au retour du réseau — événement de sync, focus, bouton manuel :
const { applied, rejected, halted, remaining } = await queue.replay();

Trois protections de plus, testées :

  • une action que personne ne sait rejouer est refusée à l'entrée, pas échouée en silence dans la file pour toujours ;
  • une action mise en file sous une ancienne version de l'application est écartée comme refusée, visiblement — attendre ne la rendra jamais rejouable ;
  • deux rejeux concurrents partagent une seule exécution : un événement de sync plus un réessai manuel appliquent une fois, pas deux.

pending() donne ce qui attend — de quoi afficher un badge « 3 actions en attente » dans l'interface.


Ce que ce package ne fait pas

  • Il ne stocke aucun jeton et ne parle pas HTTP : stockage et transport sont à toi.
  • Il ne détecte pas la perte de réseau : brancher replay() sur tes événements (sync du service worker, online, focus) est ton choix.
  • Il ne fournit pas de store IndexedDB ou SQLite : trois méthodes à écrire (append, list, remove), ton environnement les connaît mieux que lui.
  • Il ne résout pas les conflits : un refus du serveur est remonté, la réconciliation est un choix produit.
  • Il n'impose ni composants, ni hooks, ni framework.

Tests

npm test --workspace @astratra/client