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 🙏

© 2025 – Pkg Stats / Ryan Hefner

@design-edito/publisher

v0.1.15

Published

`lm-publisher`, est serveur qui ne répond que du JSON ou des fichiers, pas de frontend.

Readme

LM-Publisher

lm-publisher, est serveur qui ne répond que du JSON ou des fichiers, pas de frontend.

API

Il y a pour l'instant 5 types de réponses : void, file, json, list, error. Si le serveur ne répond pas une réponse de type error, c'est que l'opération a abouti. Ci-dessous, d'abord le détail des réponses HTTP avec les headers avant de détailler la forme de la donnée JSON reçue pour les réponses de type json, list et error.

Les réponses HTTP

Type VOID

Une réponse de type void, est une réponse vide avec un statut HTTP 204, ce sont des opérations qui ne nécessitent pas de récupérer de la donnée en réponse. Ça ne va pas concerner beaucoup de routes normalement, typiquement :

  • POST /auth/logout
  • POST /auth/logout-everywhere
  • POST /auth/refresh-token (celle là elle est un peu spéciale, more on that later)
  • POST /auth/request-email-verification-token
  • POST /auth/submit-new-password
  • Quelques autres dans la section /storage du serveur, mais cette partie là est encore expérimentale, on va pas en avoir besoin tout de suite

Le détail de comment la réponse est formée :

// Headers par défaut pour chaque route (possiblement overridées plus bas en fonction du type de réponse)
res.setHeader('Cache-Control', 'no-store')
    .setHeader('Content-Security-Policy', `default-src 'none';`)
    .setHeader('Referrer-Policy', 'no-referrer')
    .setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
    .setHeader('X-Content-Type-Options', 'nosniff')
    .setHeader('X-Request-ID', initMeta.requestId)
    .setHeader('X-Response-Time', `${Date.now() - initMeta.timestamp}ms`)
    .setHeader('X-Server-Meta', JSON.stringify({
      userId: initMeta.userId,
      requestId: initMeta.requestId,
      timestamp: initMeta.timestamp
    }))
// Fin des headers par défaut
    .status(204)
    .end()

Type FILE

Lorsque le serveur répond par un fichier.

Note pour plus tard : pour l'instant elle n'est implémentée nulle part, mais on va en faire usage pour nos générateurs d'image, et après réflexion, on va pas passer par un truc intermédiaire de genre la route renvoie une liste d'urls temporaires sur lesquelles trouver les fichiers, on va effectivement répondre un zip, j'ai vu qu'on pouvait unzip côté client et récupérer le contenu des images, donc on va faire comme ça je pense. Anyway, la réponse est créée comme ça :

// Headers par défaut, puis
res.status(fileResponse.httpStatus)
    .type(fileResponse.contentType)
    .setHeader('Content-Disposition', `attachment; filename="${fileResponse.filename}"; filename*=UTF-8''${encodeURIComponent(fileResponse.filename)}`)
    .setHeader('Cache-Control', fileResponse.cacheControl)
    .setHeader('Content-Length', fileResponse.contentLengthBytes)

Ensuite, soit le fichier est envoyé d'un bloc via res.send(fileBuffer) parce que c'est un buffer côté serveur, soit il est "streamé" via fileStream.pipe(res).

Qu'est-ce que ça veut dire ? Aucune idée.

Type JSON et LIST

Pour la plupart des routes, le serveur va répondre du JSON :

  • soit un seul objet (type json) :
        // Réponse JSON, headers par défaut, puis
        res.status(fullJsonResponse.httpStatus)
            .type('application/json; charset=utf-8')
            .setHeader('Content-Disposition', 'inline')
            .json(fullJsonResponse)
  • soit une liste d'objets partielle et paginée (type list) :
    // Réponse LIST, headers par défaut, puis
    res.status(fullListResponse.httpStatus)
        .type('application/json; charset=utf-8')
        .setHeader('Content-Disposition', 'inline')
        .setHeader('X-Total-Count', fullListResponse.total)
        .setHeader('X-Page', fullResponse.page)
        .setHeader('X-Next-Page', fullListResponse.next ?? '')
        .setHeader('X-Prev-Page', fullListResponse.prev ?? '')
        .json(fullListResponse)

Type ERROR

À chaque fois que quelque chose s'est mal passé, soit côté client (auth manquante, requête malformée, ...), soit côté serveur (mauvaise implémentation, db non accessible, etc...) :

// Headers par défaut
res.status(fullResponse.httpStatus)
    .type('application/problem+json')
    .setHeader('Content-Disposition', 'inline')
    .json(fullResponse)

Le contenu des réponses JSON, LIST et ERROR

Quand le serveur renvoie autre chose qu'une réponse void ou file, il renvoie du JSON dans response.body. La structure sera toujours :

type ServerErrorResponse<C extends Codes> = {
  success: false
  httpStatus: number
  type: 'error'
  error: ErrorData<C> // Voir plus bas
  meta: {
    userId: string | null
    requestId: string
    timestampMs: number
    elapsedMs: number
  }
}

type ServerJsonResponse<P extends object> = {
  success: true
  httpStatus: number
  type: 'json'
  payload: P
  meta: {
    userId: string | null
    requestId: string
    timestampMs: number
    elapsedMs: number
  }
}

type ServerListResponse<P extends object> = {
  success: true
  httpStatus: number
  type: 'list'
  payload: P[]
  page: number
  total: number
  prev: string | null
  next: string | null
  meta: {
    userId: string | null
    requestId: string
    timestampMs: number
    elapsedMs: number
  }
}

Ce qui compte, c'est le contenu de response.body.error ou response.body.payload, et éventuellement les détails de pagination pour les réponses list: page, total, prev (null si première), next (null si dernière).

Routes, Méthodes, Requêtes et Réponses

Toute la partie API du serveur est définie à partir d'ici : https://github.com/lm-design-edito/lm-publisher/tree/master/src/api

Dans /api/index.ts, il y a :

enum ENDPOINT = {
    // Liste des endpoints sous la forme METHOD:PATH, ex: GET:/system/status-check, POST:/auth/login, etc...
}

const ROUTES = {
    [ENDPOINT]: handlerFunction
}

Ensuite, chaque handler est défini dans son sous-dossier dans /api, exemple : /api/auth/login

Pour chaque sous-dossier de handler, il y a :

  • index.ts — Rien d'intéressant ici
  • operation.ts — La fonction qui est éxécutée pour effectuer l'opération. On y trouve normalement toujours :
    • const silentOperation, le cœur de la fonction éxécutée
    • const operation, la fonction embarquant les logs qu'elle implique
    • type OperationErrorCodes, la liste des codes d'erreurs que peut renvoyer l'opération
    • type DTO, le "data transfer object", c'est à dire la forme de l'objet qu'on va trouver dans response.body.payload, si la réponse est de type json ou list
  • validation.ts — Optionnel, la fonction qui s'occupe de valider l'input utilisateur avant de la passer à operation.ts. On y trouve :
    • type ExpectedBody, qui décrit la forme de l'objet à envoyer via request.body pour passer la validation
    • Plus tard, potentiellement type ExpectedQuery, je ne vois pas trop de cas d'usage à transmettre de la donnée via les query params de l'URL, à part pour une route qui répondrait une liste, ?page=2 pourrait avoir du sens.
  • authentication.ts — Optionnel, la fonction qui s'occupe de savoir si l'utilisateur qui fait la reqûete a les droits suffisants. Normalement assez straigntforward, ça ressemblera très souvent à ça : /api/storage/credentials/create/authentication.ts

Les codes d'erreur

La liste des codes d'erreurs que peut renvoyer le serveur en cas de réponse de type error, ainsi que le message et l'objet de détails qui y sont associés (ErrorData<Code>) est trouvable sur : /errors/index.ts

Authentification

L'authentification d'un client lorsqu'il envoie une requête au serveur repose sur deux concepts :

  • Des JSON Web Tokens, avec une logique de access & refresh tokens
  • Une protection contre les attaques CSRF, via le package NPM csurf, qui... a été archivé la veille de la rédaction de ce README 😭😭😭😭. Il n'y a pas trop de raison de bouger pour l'instant et surtout pas d'autre solution viable donc on va continuer et régler ça plus tard.

CSRF

Que l'on fasse une requête sur un endpoint qui demande l'authentification ou non, si la requête n'est pas en GET, la protection CSRF va s'interposer en premier, et par défaut, elle va répondre une réponse de type error, avec le code invalid-csrf-token.

Avant toute requête hors GET, il faut donc aller récupérer le token généré par le serveur, sur /csrf/get-token. Dans l'idée :

const res = await fetch('https://<hostname>/csrf/get-token', { credentials: 'include' })
const data = await res.json()
const csrfToken = data.payload.token

Ensuite, le token vit uniquement dans la mémoire, dans une variable, et disparait donc quand l'onglet ou le navigateur est fermé. Une fois le token récupéré, les requêtes doivent embarquer le header 'X-CSRF-Token': csrfToken, et spécifier credentials: 'include'. Ex:

await fetch('https://<hostname>/some/path', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  },
  credentials: 'include',
  body: JSON.stringify({ ... })
})

Le token a une durée de vie de 2h, il faut aller le générer à nouveau sur /csrf/get-token à chaque fois que le serveur répond invalid-csrf-token (en limitant les retries successifs, si la procédure ne fonctionne pas du premier coup, c'est probablement qu'il y a un problême côté serveur).

JWT

Dans l'idée, lorsqu'un client va s'authentifier sur /auth/login, si l'authentification réussit, le serveur va répondre avec :

  • Un access token via le Header X-Access-Token :
    res.setHeader('X-Access-Token', `Bearer ${token}`)
  • Un refresh token via le cookie refreshToken :
    res.cookie('refreshToken', token, {
        httpOnly: true,
        secure: MODE === 'production',
        sameSite: 'none',
        expires: new Date(Date.now() + REFRESH_TOKEN_EXPIRATION_SECONDS * 1000)
    })

Une fois ces tokens récupérés, le client doit idéalement persister la valeur de l'access token dans localStorage ou sessionStorage. Il peut le garder uniquement en mémoire dans une variable comme le token CSRF, mais en revanche il ne doit SURTOUT PAS être persisté via un cookie. La valeur de cet accessToken est ensuite utilisée pour chaque requête (du moins celles qui nécessitent authentification, mais c'est bien de le mettre tout le temps) :

await fetch('https://<hostname>/some/path', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`,
    'X-CSRF-Token': csrfToken
  },
  body: JSON.stringify({ ... }),
  credentials: 'include'
})

Si l'accessToken a expiré, le serveur ne va pas forcément répondre par une erreur explicite. Le middleware chargé de récupérer le contenu du token dans le header va juste considérer que la requête vient d'un utilisateur qui n'est pas authentifié, laisse passer la requête qui continue sa route vers le handler correspondant à l'URL demandée, qui lui se charge de répondre que l'utilisateur n'est pas authentifié si la route est protégée, via le code d'erreur user-not-authenticated.

Il faut donc à ce moment demander un renouvellement de l'accessToken, sur /auth/refresh-token. Pour ce faire, pas besoin de se préoccuper de la valeur du refreshToken : le serveur nous l'a fourni initialement via un cookie dont le contenu n'est pas accessible à JavaScript, et sera automatiquement inclus dans la requête :

await fetch('https://<hostname>/auth/refresh-token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  },
  credentials: 'include'
})

[WIP] à continuer, détails sur les erreurs retournées par /auth/refresh-token, etc...