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

@msbci/form-storage

v0.1.1

Published

Stockage des pieces jointes de formulaire — contrat, connecteurs S3 et local, fabrique de routeur

Readme

@msbci/form-storage

npm version license

Stockage des pièces jointes d'un formulaire, hors de la ligne enregistrée.

Une pièce déposée dans un formulaire @msbci est, par défaut, encodée en base64 dans la valeur de la réponse. Un binaire de 2 Mo pèse 2,67 Mo une fois encodé et voyage ainsi jusqu'à la base. Ce paquet porte le contrat qui permet de l'envoyer ailleurs : dans un stockage objet, sur un disque, ou vers un service qui possède déjà les pièces de l'application hôte.

Code serveur uniquement. Aucun secret de stockage ne descend dans le navigateur : celui-ci ne parle jamais à un connecteur, il parle à une route de l'hôte.

Série 0.x — ce à quoi s'en tenir

Ce paquet sort délibérément en 0.1.0, et pas en 1.0.0.

Le contrat est conçu pour être satisfait aussi bien par un connecteur qui écrit dans un magasin que par un connecteur qui appelle un service. Cette seconde espèce est éprouvée ici par un double de test (voir plus bas), mais elle ne le sera contre une implémentation réelle qu'au moment où un hôte en branchera une. Sortir en 1.x figerait un contrat que rien n'a encore confronté au terrain, et imposerait un incrément majeur au premier ajustement.

Une série 0.x annonce honnêtement que le contrat peut encore bouger. Le passage en 1.0.0 interviendra lorsqu'un connecteur délégant réel l'aura validé. D'ici là, épinglez une version exacte.

Installation

npm install @msbci/form-storage

Le connecteur S3 charge son SDK à la demande. Il n'est nécessaire que si vous l'utilisez :

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Le contrat

interface IStorageAdapter {
  readonly code: string
  readonly capabilities: {
    signedUrl: boolean   // sait produire un lien temporaire
    remove: boolean      // accepte une suppression a la demande
    list: boolean        // sait enumerer, donc se laisser rapprocher
  }

  put(input: IPutRequest): Promise<IStoredFile>
  getStream(ref: string): Promise<IReadableFile>          // seule lecture obligatoire
  head(ref: string): Promise<IStoredFile | null>

  getSignedUrl?(ref: string, options?: ISignedUrlOptions): Promise<ISignedUrl>
  remove?(ref: string): Promise<void>
  list?(scope: Partial<IStorageScope>): AsyncIterable<IStoredObject>
}

Dix règles le gouvernent, et trois d'entre elles ne sont pas négociables.

  1. Aucune notion de compartiment, de clef, de chemin ni de région dans la surface publique. L'appelant ne nomme jamais l'endroit : il remet un contenu et reçoit un identifiant opaque.
  2. L'identifiant est frappé par le connecteur, jamais par l'appelant, jamais dérivé du nom du fichier. C'est ce qui interdit à deux déposants d'attestation.pdf de se recouvrir, et ce qui empêche un nom hostile de déplacer l'objet.
  3. Toutes les opérations sont asynchrones et faillibles, avec une famille d'erreurs qui distingue « refusé », « introuvable » et « le stockage n'a pas répondu ». Un connecteur distant échoue pour des raisons qu'un connecteur local ne connaît pas.
  4. Le contenu voyage en flux, jamais en chemin de fichier.
  5. Les capacités sont déclarées, pas supposées. (non négociable)
  6. getStream est la seule opération de lecture obligatoire. (non négociable) Le lien signé est une optimisation ; le chemin universel est « l'hôte sert les octets ». C'est aussi ce qu'imposent les politiques de sécurité de contenu strictes, qui interdisent de récupérer en fetch ou d'afficher en <img> une ressource servie par un hôte tiers.
  7. La suppression peut être refusée. (non négociable) Un connecteur a le droit de répondre « le cycle de vie ne m'appartient pas ». Sans cette règle, le contrat obligerait un connecteur délégant à contredire le service qui fait autorité.
  8. La portée de dépôt embarque un contexte opaque à l'hôte, transporté jusqu'au connecteur sans jamais être interprété par le paquet.
  9. Une clef d'idempotence accompagne le dépôt. Un envoi rejoué sur une liaison lente ne crée pas deux objets.
  10. Aucune énumération n'est supposée. Le rapprochement stockage / base est facultatif.

La valeur portée par une soumission

interface IStoredFile {
  kind: 'stored-file'      // discriminant explicite : distingue d'une data URL
  ref: string              // identifiant opaque rendu par le connecteur
  fileName: string         // nom d'origine, conserve tel quel
  contentType: string      // type retenu par le connecteur, pas celui annonce par le client
  size: number
  checksum?: { algo: 'sha256'; value: string }
  uploadedAt: string       // ISO 8601
  adapter: string          // code du connecteur qui a ecrit
}

ref est opaque : ni chemin, ni clef, ni URL. Personne d'autre que le connecteur ne l'interprète.

adapter existe parce qu'un changement de cible n'est pas rétroactif : un fichier déposé sous une cible reste lu par le connecteur qui l'a écrit, même après bascule. Sans ce champ, changer de cible rendrait illisibles tous les fichiers déjà déposés, silencieusement.

Nom unique côté stockage, nom d'origine conservé. Deux déposants qui envoient attestation.pdf obtiennent deux références distinctes, et chacun retrouve son fichier sous son nom d'origine — le nom voyage dans les métadonnées et revient en Content-Disposition, jamais dans l'identifiant.

La portée

interface IStorageScope {
  tenantId?: string
  formId: string
  submissionId?: string    // absent tant que la soumission n'existe pas
  variableCode: string
  instanceNumber?: number
  context?: Record<string, string>   // opaque : le paquet ne l'interprete jamais
}

Le vocabulaire est celui d'un formulaire, et rien d'autre. context est le point charnière : l'hôte y dépose ce que son connecteur doit savoir — l'identifiant d'un objet métier, par exemple — et le paquet le transporte sans jamais le lire.

Les erreurs

| Erreur | Code | Sens | |---|---|---| | StorageRejectedError | STORAGE_REJECTED | Refus : size, content-type, forbidden, invalid. Non retentable. | | StorageNotFoundError | STORAGE_NOT_FOUND | La référence ne désigne rien. | | StorageUnavailableError | STORAGE_UNAVAILABLE | Le stockage n'a pas répondu. Retentable. | | StorageUnsupportedError | STORAGE_UNSUPPORTED | Capacité non offerte. Ce n'est pas une panne. |

Les connecteurs livrés

S3 et compatibles

import { createS3StorageAdapter } from '@msbci/form-storage'

const adapter = createS3StorageAdapter({
  bucket: process.env.STORAGE_BUCKET!,
  endpoint: process.env.STORAGE_ENDPOINT,
  region: process.env.STORAGE_REGION,
  accessKeyId: process.env.STORAGE_ACCESS_KEY!,
  secretAccessKey: process.env.STORAGE_SECRET_KEY!,
  forcePathStyle: true,
  // La signature couvre l'hote : un lien signe sur l'adresse interne du service
  // n'est pas valable depuis un navigateur.
  publicEndpoint: process.env.STORAGE_PUBLIC_ENDPOINT,
  maxBytes: 10 * 1024 * 1024,
  allowedContentTypes: ['application/pdf', 'image/jpeg', 'image/png'],
})

Capacités : signedUrl, remove, list — les trois.

Deux points de mise en œuvre méritent d'être connus.

  • Les métadonnées utilisateur d'un objet S3 sont ASCII. Un nom de fichier en UTF-8 y est encodé en pourcentage et décodé à la lecture. Omis, ce détail produit un nom cassé six mois plus tard.
  • La lecture est un flux de bout en bout ; l'écriture, elle, lit la source jusqu'à maxBytes avant d'émettre l'objet, la taille et l'empreinte devant accompagner ses métadonnées. La mémoire est donc bornée par l'exploitant, jamais par l'appelant : le dépassement est refusé pendant la lecture, pas après.

Stockage local

import { createLocalStorageAdapter } from '@msbci/form-storage'

const adapter = createLocalStorageAdapter({ rootDir: '/var/lib/form-attachments' })

Capacités : remove et list. Pas de lien signé — l'hôte sert les octets.

Utile en développement et sur un déploiement à une seule instance. Sans volume partagé, il ne survit ni au remplacement d'un conteneur ni à une seconde instance : la moitié des fichiers devient invisible, sans erreur. C'est écrit ici parce que la panne, elle, ne le dira pas.

L'écriture se fait sous nom temporaire puis renommage : un lecteur ne tombe jamais sur un fichier à moitié écrit.

La fabrique de routeur

createStorageRouter rend un gestionnaire indépendant du cadriciel, à monter sur Express, Fastify ou une route Next.js — sur le modèle de createFormRouter.

import { createStorageRouter } from '@msbci/form-storage'

const router = createStorageRouter({
  adapter,
  authenticate: async (request) => resolveSession(request.headers),
  resolveScope: async (request, identity) => ({
    formId: String(request.query.formId),
    variableCode: String(request.query.variableCode),
    // L'hote lie ce qu'il veut : le paquet ne lira jamais ce contexte.
    context: { orderId: identity.orderId as string },
  }),
  authorizeRef: async (ref, action, identity) => canAccess(identity, ref, action),
})

| Route | Effet | |---|---| | POST /files | Dépôt. Nom d'origine en en-tête x-file-name (encodé en pourcentage) ou en paramètre fileName. Clef d'idempotence en x-idempotency-key. | | GET /files/:ref | Sert les octets en même origine, sous le nom d'origine. ?download=1 force le téléchargement. | | GET /files/:ref/metadata | Rend la référence complète. | | GET /files/:ref/signed-url | Lien temporaire, si le connecteur l'offre. 405 sinon. | | DELETE /files/:ref | Suppression, si le connecteur l'accepte. 405 sinon. |

Trois crochets sont obligatoires, et aucun n'a de valeur par défaut permissive :

  • authenticate — un retour null vaut refus. Le routeur sert des pièces, il n'a pas de mode ouvert.
  • resolveScope — la portée est décidée par l'hôte à partir de son propre routage, jamais lue dans le corps de la requête.
  • authorizeRefune référence n'est pas un droit. Détenir l'identifiant d'un fichier ne prouve rien ; l'hôte seul sait à quel objet métier il se rattache. Un refus est formulé en 404, pas en 403 : l'appelant ne doit pas apprendre que la référence existe.

Éprouver un connecteur

Tout connecteur — livré ici ou écrit par un hôte — doit passer la suite de conformité. Elle est publiée sous @msbci/form-storage/testing et ne dépend d'aucun cadriciel de test : elle rend une liste de contrôles nommés, que vous déroulez avec l'outil de votre choix.

Ce sous-chemin désigne le même module que la racine, et non un second paquet compilé. C'est délibéré : deux bundles porteraient chacun leur copie des classes d'erreur, et instanceof serait faux entre l'appelant et la suite de conformité — une panne discrète et pénible à diagnostiquer. Les mêmes symboles sont donc importables depuis @msbci/form-storage.

import { storageConformanceChecks } from '@msbci/form-storage/testing'

for (const check of storageConformanceChecks({ adapter: monConnecteur, maxBytes: 6 * 1024 * 1024 })) {
  it(check.name, () => check.run())
}

Ou, sans cadriciel :

import { runStorageConformance } from '@msbci/form-storage/testing'

const report = await runStorageConformance({ adapter: monConnecteur })
console.log(`${report.passed} passes, ${report.failed} echecs`)

La suite n'exige ni suppression, ni énumération, ni lien signé : un connecteur qui les refuse passe la conformité, à condition de le déclarer dans capabilities. Elle vérifie en revanche que la déclaration et le comportement coïncident — une capacité annoncée à true sans méthode est un échec, une méthode présente alors que la capacité est à false doit lever StorageUnsupportedError.

Le double « service distant »

@msbci/form-storage/testing expose aussi createRemoteServiceFixture() : un connecteur qui ne possède rien — pas de compartiment, pas de répertoire, pas de clef d'objet — et le service qu'il appelle. Le connecteur n'a qu'une adresse et une fonction fetch ; il ne frappe aucun identifiant, ne reconnaît aucun type et ne calcule aucune empreinte : le service en décide, il relaie. Il refuse par ailleurs la suppression et l'énumération.

Qu'il passe la même suite que les connecteurs S3 et local est la démonstration que le contrat tient sa promesse : il est satisfaisable par une implémentation qui appelle un service, et pas seulement par une qui écrit dans un magasin. C'est aussi la référence à lire avant d'écrire un connecteur délégant.

Compatibilité

Ce paquet est additif : il n'est consommé par aucun autre paquet @msbci à ce jour. Installer @msbci/form-storage ne change rien au comportement de form-core, form-renderer ou form-server, qui continuent d'encoder les pièces en base64 tant qu'un hôte ne les branche pas explicitement.


v0.1.1

  • Le paquet publie empechait le processus Node qui l'importait de se terminer. L'obfuscation posait une protection anti-debogage a intervalle, c'est-a-dire un setInterval jamais arrete : la boucle d'evenements restait occupee, et tout script, outil en ligne de commande ou tache d'integration continue qui importait le paquet restait suspendu jusqu'a son delai d'expiration, sans message et sans cause visible. Le defaut touchait ce paquet de plein fouet, puisqu'il n'existe que pour etre monte dans un processus serveur. La protection est retiree — elle vise la console d'un navigateur et ne protegeait rien sur une cible Node, tandis que les instructions debugger qu'elle injectait interrompaient le debogage legitime d'un consommateur. Ce qui rend le code couteux a relire est conserve : tableau de chaines encode, aplatissement du flot de controle, code mort, renommage des identifiants.
  • Aucun changement de contrat ni de comportement. IStoredFile tel que ce paquet le declare est desormais aussi declare par @msbci/form-core v1.13.0, a l'identique, pour que le rendu puisse lire une reference sans embarquer de code serveur. Les deux declarations sont de meme forme et se satisfont l'une l'autre : ce paquet n'importe rien du coeur, et le coeur n'importe rien d'ici. Tests inchanges.

v0.1.0

Première version. Le paquet n'a aucun consommateur : il est publiable et éprouvable seul.

  • Le contrat (IStorageAdapter, IStoredFile, IStorageScope, IPutRequest) et sa famille d'erreurs (StorageRejectedError, StorageNotFoundError, StorageUnavailableError, StorageUnsupportedError). Il est écrit pour être satisfait par un connecteur qui appelle un service autant que par un connecteur qui écrit dans un magasin : capacités déclarées, lecture en flux seule obligation, suppression et énumération refusables.
  • Nom unique côté stockage, nom d'origine conservé. L'identifiant est frappé par le connecteur et ne contient rien de ce que le client transmet ; le nom d'origine voyage dans les métadonnées et revient en Content-Disposition. Deux dépôts successifs d'attestation.pdf ne se recouvrent pas et se relisent chacun sous son nom.
  • Connecteur S3 et compatibles — dépôt, lecture en flux, métadonnées, lien signé à durée bornée, suppression, énumération par préfixe. Le SDK est chargé à la demande et déclaré en dépendance de pair facultative : le paquet reste installable et chargeable sans lui. Le point d'entrée de signature est dissociable du point d'entrée interne, la signature couvrant l'hôte. Les noms de fichiers UTF-8 sont encodés en pourcentage dans les métadonnées utilisateur, qui sont ASCII.
  • Connecteur local — écriture sous nom temporaire puis renommage, métadonnées en fichier accompagnant, énumération par portée. Pas de lien signé : l'hôte sert les octets.
  • Fabrique de routeur createStorageRouter, indépendante du cadriciel, avec trois crochets obligatoires et sans défaut permissif : authentification, résolution de portée, et autorisation par référence — une référence n'est pas un droit, et un refus de lecture est un 404.
  • Type réel déterminé par signature binaire. Le type annoncé par le client est confronté au contenu, jamais retenu tel quel ; l'extension ne tranche que ce que la signature ne distingue pas (les formats bureautiques, qui sont des archives ZIP). Liste blanche facultative par allowedContentTypes.
  • Borne d'exploitation maxBytes (défaut 10 Mio), refusée pendant la lecture du flux et non après : la mémoire du serveur ne peut pas être dictée par l'appelant.
  • Idempotence — une clef d'idempotence rejouée retombe sur la même référence et ne crée pas un second objet. La clef, qui vient de l'appelant, est hachée avec la portée : rien de ce que le client transmet n'atteint le chemin de l'objet.
  • Suite de conformité publiée sous @msbci/form-storage/testing, sans dépendance à un cadriciel de test, accompagnée d'un double « service distant » qui prouve que le contrat est satisfaisable sans magasin. Le sous-chemin désigne le même module que la racine : un second bundle aurait porté sa propre copie des classes d'erreur, rendant instanceof faux entre l'appelant et la suite.
  • Vérification : 104 tests. La conformité est jouée sur les trois connecteurs — S3 contre un vrai serveur compatible S3, local, et le double appelant un service. La lecture en flux est établie par mesure : lecture d'un fichier de 48 Mio en relâchant chaque morceau contre lecture conservant les morceaux, la première coûtant moins du huitième de la seconde, et un consommateur qui renonce après un morceau n'ayant pas fait lire le fichier entier.

License

Voir LICENSE.md.