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

@zevra/retours

v0.3.0

Published

Retours des utilisateurs d'une app Zevra : bouton, page d'aide et relais /api/retours vers l'admin.

Readme

@zevra/retours — les retours des utilisateurs d'une app Zevra

Un bouton (« Aide ») qui ouvre un tiroir à deux onglets, Signaler et Mes retours, la même chose dans une page d'aide, et le relais serveur /api/retours/* qui parle à l'admin Zevra.

L'app ne connaît que l'admin. C'est l'admin qui choisit, à l'arrivée de chaque retour, où il part : au guichet Support pour une app en exploitation, au board Projet de la mission dans le cockpit pour une app en réalisation. Rien dans ce paquet ne le sait, et rien ne l'affiche.

La clé ne va jamais dans un navigateur. Le relais (@zevra/retours/serveur) la détient et refuse de se charger dans un navigateur ; les composants (@zevra/retours) n'appellent que /api/retours/* sur le domaine de l'app.

Contrat complet : docs/retours.md du dépôt zevra-admin.

Installation

pnpm add @zevra/retours

Le paquet est publié en source TypeScript (comme @zevra/support-widget). Dans next.config.ts :

const config: NextConfig = {
  transpilePackages: ['@zevra/retours'],
}

⚠️ Sans cette ligne, le build échoue : Next ne compile pas le TypeScript d'un paquet de node_modules de lui-même.

Variables

Posées par l'admin dans les pods de l'app quand on allume les retours pour un environnement (Espace client → Retours). Rien à créer à la main.

| Variable | Valeur | |---|---| | RETOURS_URL | l'URL interne de l'admin (http://zevra-admin.zevra-admin.svc.cluster.local) | | RETOURS_API_KEY | zr_<env>_…, propre à l'app et à l'environnement | | RETOURS_APP | le slug de l'app |

Le relais est actif quand les trois variables sont présentes (non vides) ; sinon GET /api/retours/etat répond { actif: false }, retoursActifs() rend false, et le bouton ne s'affiche pas. RETOURS_APP compte autant que les deux autres : le slug fait partie du chemin de chaque appel, et un pod qui aurait l'URL et la clé sans lui (pose interrompue) afficherait un bouton dont chaque envoi échoue. Éteindre un environnement depuis l'admin retire les variables et relance le pod : le bouton disparaît.

La pose, en trois fichiers

1. Le relais — app/api/retours/[...chemin]/route.ts

import { creerRoutesRetours } from '@zevra/retours/serveur'
import { getSession } from '@/lib/session'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const routes = creerRoutesRetours({
  // ⚠️ L'identité vient de VOTRE session, jamais du corps de la requête —
  // et l'adresse rendue doit être VÉRIFIÉE par l'app (voir plus bas).
  identifier: async () => {
    const session = await getSession()
    const utilisateur = session?.user
    if (!utilisateur?.email || !utilisateur.emailVerified) return null
    return { email: utilisateur.email, nom: utilisateur.name ?? null, idExterne: utilisateur.id ?? null }
  },
  // Facultatif : vos échecs (non configuré, admin injoignable, refus…).
  journaliser: (e) => console.warn('[retours/relais]', e),
})

export const GET = routes.GET
export const POST = routes.POST

Ces deux lignes compilent telles quelles sous next build, Next 15 comme Next 16, turbopack comme --webpack : le second argument des gestionnaires est déclaré { params: Promise<{ chemin?: string[] }> }, la seule forme que les deux contrôles de types de Next acceptent. ⚠️ Gardez le nom du dossier, [...chemin] : le nom du segment fait partie de ce type.

⚠️ identifier doit rendre une adresse VÉRIFIÉE par l'app (lien de confirmation, SSO, invitation). Le relais écrit et lit les retours AU NOM de cette adresse : une app qui ouvre ses comptes sans vérifier l'adresse laisse n'importe qui s'inscrire sous celle d'un autre — un client, un membre de l'équipe — puis écrire en son nom, et lire ses retours et ses échanges avec l'équipe dans « Mes retours ». Une session dont l'adresse n'est pas vérifiée rend null (401 non_connecte) ; c'est à l'app de le décider, le paquet ne voit que ce que identifier lui rend.

| Chemin | Méthode | Ce qui se passe | |---|---|---| | etat | GET | { actif } d'après les trois variables, sans identité, sans appel à l'admin | | retours | POST | crée un retour ; auteur = la session ; Idempotency-Key relayée | | retours | GET | les retours de la personne connectée (cet environnement seulement) | | retours/<uuid> | GET | un retour et son fil | | retours/<uuid>/messages | POST | répondre dans le fil (4000 caractères au plus) ; Idempotency-Key relayée |

Réponses : celles de l'admin, reprojetées sur les champs du contrat ; ses erreurs passent telles quelles ({ erreur: { code, message, champs? } }, Retry-After compris). Toutes les réponses portent Cache-Control: no-store. Les refus propres au relais :

| Code | Statut | Quand | |---|---|---| | non_connecte | 401 | identifier rend null (ou jette) | | origine_refusee | 403 | POST dont Sec-Fetch-Site n'est pas same-origin (site frère, autre origine) | | introuvable | 404 | chemin inconnu du relais | | methode_refusee | 405 | mauvaise méthode pour ce chemin (Allow donné) | | format_refuse | 415 | POST dont le corps n'est pas annoncé Content-Type: application/json | | validation | 422 | Idempotency-Key mal formée ; corps de création de plus de 21 Mio (pieces) ; réponse vide ou de plus de 4000 caractères, ou corps de réponse de plus de 64 Kio (corps) | | non_configure | 503 | variables absentes ou mal formées | | admin_injoignable | 503 | l'admin ne répond pas (réseau) | | delai | 504 | délai dépassé : 90 s pour une création, 10 s sinon | | interne | statut de l’admin, ou 502 | réponse de l’admin hors contrat (corps illisible : son statut est gardé ; succès de forme inattendue : 502) |

Le corps d'un POST est lu en flux et coupé à la borne, Content-Length ou pas : un envoi par morceaux de plusieurs Go n'est jamais lu en entier avant le refus (mesuré : 200 Mo envoyés, ~23 Mo lus avant la 422).

⚠️ Un POST doit venir de la page de l'app, en JSON. Les cookies de session sont en SameSite=Lax, ce qui n'arrête pas un site frère : toute autre app sous *.zevra.tech pourrait, par un simple <form enctype="text/plain">, poster un retour au nom de la personne connectée. Le relais refuse donc tout POST dont Sec-Fetch-Site n'est pas same-origin, ou dont le Content-Type n'est pas application/json (qu'un formulaire ne sait pas envoyer). Les composants du paquet le font déjà ; un client écrit à la main doit faire de même. Il n'y a pas de comparaison Origin/Host : derrière un proxy qui réécrit Host, elle refuserait les envois légitimes.

⚠️ Si l'app a un proxy (src/proxy.ts) ou une liste de chemins ouverts sans abonnement, /api/retours doit y figurer comme /api/support — sinon le bouton disparaît le jour où l'interrupteur s'allume.

2. Le bouton — dans le layout des pages connectées

BoutonRetours est un composant client : un layout serveur le pose directement, avec des props sérialisables.

// app/(app)/layout.tsx — composant serveur, sous la session
import { BoutonRetours } from '@zevra/retours'
import { retoursActifs } from '@zevra/retours/serveur'

// … dans le rendu :
<BoutonRetours
  // Décidé au serveur : pas d'attente de `GET /api/retours/etat`. Sans cette
  // prop, le bouton pose la question lui-même au montage.
  actif={retoursActifs()}
  libelle="Aide"
  position="bas-droite"
  discret
  version={process.env.NEXT_PUBLIC_VERSION}
/>

⚠️ actif={retoursActifs()} seulement dans un layout rendu à la requête (il lit la session : c'est le cas d'un layout de pages connectées). Dans un rendu statique, la valeur serait figée AU BUILD — où aucune variable RETOURS_* n'existe — et le bouton ne s'afficherait jamais. Dans le doute, omettez la prop : le bouton demande au relais.

⚠️ contexteSupplementaire, onOuvrir et onEnvoye sont des fonctions : elles ne traversent pas la frontière serveur → client. Pour les passer, enveloppez le bouton dans un petit composant 'use client' de l'app.

Props de BoutonRetours : base (défaut /api/retours), libelle, titre, position (bas-droite | bas-gauche | haut-droite | haut-gauche | haut-centre | bas-centre), decalage, discret, marque, version, captureOfferte (défaut oui : le clic FIGE l'écran, voir plus bas), contexteSupplementaire, onOuvrir, onEnvoye(reference), actif.

PageRetours pose le même contenu dans une page (menu du compte → Aide) : écrire, suivre, lire le fil, répondre. Props : base, titre, version, captureOfferte (défaut non : sur la page d'aide, « toute la page » serait une capture de la page d'aide), contexteSupplementaire, onEnvoye, actif.

Les deux interrogent GET {base}/etat au montage et ne rendent rien tant que le relais n'a pas répondu actif: true — sauf si la prop actif est fournie, qui tranche dans les deux sens.

Ce que les composants garantissent, et que l'app n'a pas à refaire :

  • Rendus au serveur sans erreur d'hydratation (actif forcé) : rien de ce qui dépend de window ou de document n'est lu pendant le rendu ; le contexte technique est relevé à l'envoi (et à l'ouverture du repli « Ce qui sera joint »).
  • La feuille de style arrive avec le rendu, pas après : une balise <style href precedence> que React 19 monte dans le <head> (dès le HTML du serveur) et ne pose qu'une fois, même avec le bouton ET la page sur l'écran. Sous React 18, elle se rend en place, une par composant.
  • Le brouillon survit à un changement d'onglet et à la fermeture du tiroir (texte, capture, envoi en cours ou en échec avec sa clé d'idempotence) ; il n'est vidé que par un envoi réussi.
  • Une capture en cours se voit (« Capture en cours… ») : ses boutons et « Envoyer » sont désactivés le temps qu'elle finisse.
  • Le clic sur le bouton fige l'écran (≥ 0.3.0, #415) : il n'atteint pas l'app (écouteur de la fenêtre en capture : ni pointerdown, ni mousedown, ni focus), donc une liste déroulante ouverte reste ouverte, et la fenêtre visible — défilement compris — est photographiée avant que le tiroir ne prenne le focus. L'image est jointe d'office (Retirer, Recadrer sur l'image figée). ⚠️ Une liste NATIVE (<select>) est dessinée par le système : aucune capture web ne la voit ouverte, et le navigateur la ferme avant que la page ne reçoive le clic — c'est la zone de dépôt qui y répond.
  • Des images à la main : glissées dans le tiroir, collées dans le champ (⌘ V) ou choisies. Trois images au plus par retour (borne de l'admin) ; une image trop lourde ou hors PNG/JPEG/WebP est redessinée en JPEG (2560 px au plus) ; le nom du fichier ne part pas.
  • Le tiroir est une vraie modale au clavier : Tab et Maj+Tab restent dans le panneau, Échap ferme, et le focus revient à l'élément qui l'avait à l'ouverture (le bouton, à défaut).

3. Le tampon d'erreurs — le plus tôt possible

'use client'
import { useEffect } from 'react'
import { installerTamponErreurs } from '@zevra/retours'

export function TamponErreurs() {
  useEffect(() => installerTamponErreurs(), [])
  return null
}

À poser dans la coquille de l'app (layout racine), pas au montage du bouton : posé au montage, il manque les erreurs du démarrage — celles qu'on signale. Le bouton le repose en filet de sécurité ; la pose est idempotente.

Pendant une capture d'écran, le tampon cesse d'enregistrer console.error et console.warn : html-to-image y écrit ses propres échecs (feuilles de style d'une autre origine, polices), qui partaient avec le retour et chassaient les vraies erreurs du tampon (vingt lignes, circulaire). La console de l'app les reçoit toujours ; les exceptions et rejets non rattrapés restent enregistrés.

Ce qui part avec un retour

  • Une phrase (3 à 5000 caractères) — le titre est posé par l'admin.
  • Jusqu'à trois images : l'écran figé au clic, une zone tracée ou la page entière (html-to-image, chargé au clic), des fichiers glissés ou collés — 3 Mo au plus chacune, envoyées en base64 dans le corps : pas de dépôt présigné, pas de règle CORS à poser.
  • Le contexte technique, relevé à l'envoi : URL nettoyée (les valeurs des paramètres sont retirées, leurs noms gardés : ?jeton=abc&page=2 devient ?jeton&page ; le fragment part entièrement), navigateur, système, taille de fenêtre, langue, version, les 20 dernières erreurs de console, et ce que contexteSupplementaire ajoute (20 clés au plus, nommées de 1 à 100 caractères, valeurs scalaires de 500 caractères au plus ; une clé hors bornes est écartée, pas coupée).
  • L'auteur, tiré de la session par identifier : l'adresse, le nom (ramené à 200 caractères) et l'identifiant externe (omis s'il dépasse 200 caractères — un identifiant coupé désignerait quelqu'un d'autre ; l'adresse reste l'identité). Au-delà de ces bornes, l'admin refuserait TOUT le retour en 422, avec un message que la personne ne peut pas corriger.
  • Une Idempotency-Key par envoi : réessayer le même envoi (même texte, même capture) rejoue la même clé et le même corps, et l'admin rend le retour déjà créé au lieu d'en faire un second. Une réponse dans le fil (4000 caractères au plus — le cockpit coupe au-delà) porte aussi la sienne, la même au réessai : l'admin la rejoue au guichet au lieu de doubler le message (le cockpit n'en a pas encore : un réessai après un délai dépassé peut y doubler une réponse).

⚠️ Pour une app en réalisation, le contexte finit dans une tâche que le client voit dans son board. N'y mettez rien qu'il n'ait vu lui-même.

Le lien vers l'espace client (0.2.0)

« Mes retours » affiche « Suivre dans votre espace client ↗ », et le fil d'un retour arrivé au board Projet « Voir dans votre espace client ↗ » (sa tâche, ?tache=BSP-12), dans un nouvel onglet. Rien à poser dans l'app : l'admin Zevra n'offre ces liens qu'à une adresse qui peut ouvrir l'espace (compte lié à l'app, ou équipe Zevra, portail activé) ; pour les autres, rien ne s'affiche. Le relais revérifie la forme (/espace/<RETOURS_APP> en https) avant de laisser passer quoi que ce soit.

Ce qu'il ne faut pas faire

  • ⛔ Aucune variable NEXT_PUBLIC_RETOURS_*. La clé dans un bundle se lit dans l'onglet Réseau de n'importe quel visiteur. Si un appel échoue en 401 « pour tester », la réponse n'est jamais de renommer la variable.
  • ⛔ Ne lisez pas l'identité dans le corps de la requête (« l'adresse est dans le formulaire »). Le relais ignore auteur et email reçus du navigateur ; votre identifier ne doit lire que la session.
  • ⛔ Ne recopiez pas les retours dans la base de l'app. L'admin est l'index (et le guichet ou le cockpit, la source) ; une copie locale divergerait au premier changement de statut et afficherait une histoire que l'équipe ne voit pas.
  • ⛔ N'importez pas @zevra/retours/serveur dans un composant client : il jette au chargement, exprès.
  • ⛔ Ne mettez pas RETOURS_URL sur admin.zevra.tech : depuis un pod, l'URL publique fait une boucle par le répartiteur (hairpin) qui échoue en silence.

Tests

Le paquet a sa propre chaîne (la racine de zevra-admin exclut packages/) :

cd packages/retours
pnpm install
pnpm typecheck && pnpm test

Ce qui est couvert : chaque chemin du relais et la liste blanche, la méthode refusée, l'identité absente (401), le relais non configuré (503), actif qui exige les trois variables (et GET etat d'accord avec retoursActifs()), le POST d'un site frère refusé (403 sur Sec-Fetch-Site, 415 sur un corps non JSON), l'auteur du corps ignoré, l'auteur ramené aux bornes de l'admin, la clé dans x-retours-key et jamais dans Authorization, Idempotency-Key relayée sur la création ET sur la réponse dans le fil (jamais sur une lecture), la réponse bornée à 4000 caractères, le corps lu en flux et coupé à la borne (envoi par morceaux sans Content-Length compris), Retry-After reposé, les délais de 90 s et 10 s, no-store partout, la reprojection qui retire tout ce qui n'est pas au contrat ; les types exportés face aux deux contrôles de next build (types-next.test.ts, vérifié par pnpm typecheck) ; retoursActifs ; la validation de saisie aux bornes du contrat ; le nettoyage d'URL et les bornes du contexte (noms de clés d'extra compris) ; la géométrie de la zone ; le tampon d'erreurs et sa suspension pendant la capture ; le décodage de capture ; le rejeu à l'identique d'un envoi et d'une réponse ; le brouillon (brouillon.ts) et le focus du tiroir (focus.ts) ; la feuille de style (préfixe zvr-, un repli par token).

Les composants sont aussi testés rendus : au serveur, sous node (rendu-serveur.test.tsx : même HTML avec et sans window, feuille posée une fois, dans le <head>), et montés dans un document happy-dom (composants.test.tsx : brouillon gardé à travers les onglets et le tiroir, vidé après un envoi réussi seulement ; capture en cours ; piège de focus et retour du focus ; feuille unique).

Publication

Workflow .github/workflows/retours-paquet.yml de zevra-admin : contrôles à chaque push ou PR qui touche packages/retours/** ; publication sur npm sur une étiquette retours-v<version> (qui doit être celle du package.json) ou par lancement manuel avec « publier » coché.