Créer un module

Écrire un module Fraza, manifeste, hook register, frontières serveur/client, tables et build.

Un module est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec @fraza/core, sans aucune modification de code dans le projet consommateur.

npm install @fraza/module-forms
# ajouter les variables d'environnement du module au .env
npx fraza-db init   # crée les tables du module et applique ses permissions
npm run dev        # pages admin, sidebar, widgets, API, tout est câblé

Aucun fichier de configuration manuelle : la plateforme découvre les modules en scannant les dépendances du package.json.

Ce guide couvre l'écriture d'un module. Pour la mécanique de découverte, de synchronisation et de boot, voir Cycle de vie d'un module.

Le contrat

Le point d'entrée du package doit exporter :

ExportTypeObligatoire
manifestobjetoui
register() => void | Promise<void>oui
createTablesasync () => { created?, skipped? }si le module a des tables
dropTablesasync () => voidsi le module a des tables
// @fraza/module-blog — src/index.js
import { MODULE_VERSION } from './version.js';

export const manifest = {
  name: '@fraza/module-blog',
  version: MODULE_VERSION,
  permissions: [
    { key: 'blog.view',   name: 'Voir les billets',  description: 'Consultation', group_name: 'Blog' },
    { key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD',         group_name: 'Blog' },
  ],
  envVars: [
    { key: 'FRAZA_MODULE_BLOG',            required: true,  description: 'Active le module blog (true / false).' },
    { key: 'FRAZA_MODULE_BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias (défaut public/blog).' },
  ],
};

export async function register() {
  await import('./register-server.js');
}

export { createTables, dropTables } from './db.server.js';

Manifeste

ChampTypeRôle
namestringNom du package, identifiant unique du module.
versionstringVersion, journalisée au boot. Importe-la de src/version.js, généré par node scripts/sync-version.js : un littéral recopié à la main dérive dès la publication suivante, et fraza-doctor le signale.
requiresCorestringVersion minimale de @fraza/core. Voir Déclarer la version de core exigée.
requiresModulesstring[]Noms de paquet des modules dont les tables doivent exister avant celles-ci. Voir Dépendre d'un autre module.
permissionsArrayPermissions ajoutées au catalogue. Attribuées au rôle admin au prochain fraza-db init.
envVarsArrayVariables du module. Voir Déclarer les variables d'environnement.

Déclarer les variables d'environnement

Déclare toutes les variables que le module lit, pas seulement celles qui n'ont pas de valeur par défaut. manifest.envVars n'est pas une liste de réglages obligatoires, c'est le catalogue par lequel fraza-doctor reconnaît une clé du .env d'un projet. Une variable lue par le module mais absente du manifeste est dénoncée comme n'est déclarée ni par le core ni par un module installé chez tout projet qui la renseigne, ce qui est un faux positif désagréable et impossible à faire taire côté projet.

required répond à une seule question : le module peut-il fonctionner sans cette variable ?

requiredQuandEffet
trueAucun repli n'existe. L'interrupteur FRAZA_MODULE_<NOM> du module, typiquement.Absente du .env, elle est bloquante au diagnostic.
falseUne valeur par défaut s'applique, ou la fonctionnalité est facultative.Jamais signalée absente. Reste au catalogue.

Une description en français, avec la valeur par défaut et les bornes quand il y en a. C'est le texte qu'affichent fraza-doctor et le --fix.

Le core s'applique la même règle à lui-même : il déclare ses propres variables dans src/core/doctor/env.js, au même format à trois champs, et le catalogue du doctor est l'union des deux. Un module n'a donc pas à redéclarer une variable du core.

Une conséquence utile : le générateur de notes de version compare les clés de manifest.envVars entre deux versions. Une variable ajoutée au manifeste est annoncée toute seule dans le CHANGELOG.md du module, et fraza-update la montre à qui met à jour. Un gabarit comme FRAZA_MODULE_POSTS_TYPE_<KEY> en est exclu, puisqu'il ne nomme aucune variable réelle.

Frontières serveur et client

Un module avec une partie admin expose deux entrées, et une troisième s'il sert une page publique.

{
  "exports": {
    ".":             { "import": "./dist/index.js" },
    "./client":      { "import": "./dist/client.js" },
    "./public-page": { "import": "./dist/public-page.js" }
  }
}
EntréeBundleContenu
.serveurmanifest, register(), createTables, dropTables. Le register() tire la chaîne register-server.js : routes API, navigation, fetchers, préfixes de stockage, hooks base.
./clientclient'use client'. Uniquement registerPage({ Component }), registerWidget({ Component }) et registerAdminIcons({ … }). Aucun import vers pg, fs, un .server.js ou register-server.js.
./public-pageserveur, graphe RSC de la routeLe seul registerPublicModulePage() du module. Pas de 'use client' sur le fichier lui-même : ses enfants peuvent l'être, c'est même tout l'intérêt.

La séparation est obligatoire : tout ce qui est importé statiquement depuis l'entrée client finit dans le bundle navigateur, où pg, fs et next/headers échouent.

Chaque entrée a son point d'accroche dans le projet hôte, et il n'est pas interchangeable : . est tiré par instrumentation.js, ./client par <FrazaModulesClient /> dans app/(admin)/admin/layout.js, ./public-page par app/fraza/[...path]/page.js. Voir Cycle de vie d'un module.

Ne jamais déclarer "sideEffects": false dans le package.json d'un module : les manifestes générés reposent sur des imports dont l'effet est justement d'exister. Le core, lui, déclare une liste ciblée qui ne laisse purs que ses icônes et ses composants : une page publique de module peut donc importer Button ou une icône par les barrels du core sans embarquer le reste. Voir Effets de bord et barrels.

// src/register-server.js — appelé par register()
import { registerNavItem, registerNavSection, registerPageMeta } from '@fraza/core/features/admin';
import { registerApiRoutes } from '@fraza/core/api';
import { routes } from './api.server.js';

registerNavSection({ id: 'blog', title: 'Blog', icon: 'BookBookmark02Icon', order: 40, group: 'content' });
registerNavItem({ id: 'blog-posts', label: 'Billets', icon: 'BookBookmark02Icon', href: '/admin/blog', sectionId: 'blog', permission: 'blog.view' });
registerPageMeta({ slug: 'blog', title: 'Blog', description: 'Gestion des billets' });
registerApiRoutes(routes);
// src/client.js — chargé par le manifeste client uniquement
'use client';
import { registerPage } from '@fraza/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';

registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });

Aucun composant .client.js n'est importé depuis register-server.js, et aucun fichier serveur depuis client.js.

Sous-entrées sûres côté client

Le client.js ne doit jamais importer un barrel mixte. Le catalogue complet est dans Points d'entrée.

En résumé, sont sûrs : @fraza/core/users/constants, @fraza/core/features/admin, .../admin/components, @fraza/core/media-registry/client, @fraza/core/blocks-registry/client, .../fields-ui, @fraza/core/themes, @fraza/core/toast, @fraza/core/shared/icons, .../components, .../hooks.

Ne le sont pas : @fraza/core/users complet, @fraza/core/database, @fraza/core/api, @fraza/core/storage.

Pas de next/headers au top-level

Tout fichier atteignable depuis register() ne doit pas importer next/headers ni next/navigation au top-level. L'import doit être lazy dans le handler. La raison est détaillée dans Frontières serveur/client.

export async function handleSomething(request) {
  const { cookies } = await import('next/headers');
}

Enregistrer des fonctionnalités

Tous ces appels se font depuis register-server.js ou client.js, selon leur côté.

Identique aux extensions in-projet. Voir Étendre l'admin.

registerNavSection({ id: 'blog', title: 'Blog', icon: 'BookBookmark02Icon', order: 40, group: 'content' });
registerNavItem({ id: 'blog-posts', label: 'Billets', icon: 'BookBookmark02Icon', href: '/admin/blog', sectionId: 'blog', permission: 'blog.view' });

Icônes du module

Le core ne livre que les icônes de sa propre interface. Une icône qui n'est pas dans son jeu se recopie dans le module, une par fichier, dans src/icons/<Nom>.js, depuis le jeu libre Hugeicons, avec un trait de 1,8. Les composants du module l'importent par son chemin :

import { InvoiceIcon } from '../icons/InvoiceIcon.js';
import { Add01Icon } from '@fraza/core/shared/icons';

La navigation reçoit des noms d'icônes, parce qu'elle passe du serveur au client en données sérialisables. Une icône du module citée par son nom s'enregistre dans client.js, au même endroit que registerPage :

// src/client.js
'use client';
import { registerAdminIcons, registerPage } from '@fraza/core/features/admin';
import { InvoiceIcon } from './icons/InvoiceIcon.js';

registerAdminIcons({ InvoiceIcon });
// src/register-server.js
registerNavSection({ id: 'billing', title: 'Facturation', icon: 'InvoiceIcon', order: 150, group: 'tools' });

Recopier une icône, c'est distribuer son code : l'entrée Hugeicons (MIT) va dans la liste VENDORED de scripts/generate-notices.js, avec l'emplacement dist/icons/. Voir Icônes d'un module.

Widgets

// serveur
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));

// client
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });

Routes API

import { registerApiRoutes, defineApiRoutes } from '@fraza/core/api';

const routes = defineApiRoutes([
  { path: '/blog/posts',     method: 'GET',  handler: handleListPosts,  auth: 'admin', permission: 'blog.view' },
  { path: '/blog/posts',     method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
  { path: '/blog/posts/:id', method: 'GET',  handler: handleGetPost,    auth: 'public' },
]);

registerApiRoutes(routes);

Le routeur applique la session, la limitation de débit et la vérification de permission. Les routes sont servies sous /fraza/api/*. Voir Ajouter une route API.

Pages publiques du module

L'appel va dans src/public-page.js, exposé par la sous-entrée ./public-page. Jamais dans register-server.js.

// src/public-page.js
import { registerPublicModulePage } from '@fraza/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.server.js';

registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });

Servies sous /fraza/blog/…. Le composant reçoit { params, searchParams, segments } et fait son propre routage. searchParams porte la chaîne de requête résolue, ce qui permet de lire un retour de redirection externe comme ?paiement=succes. Le moduleName api est réservé.

La raison du placement : register-server.js est atteint depuis instrumentation.js, une compilation distincte de celle de la route. Un composant enregistré là échoue au rendu dès qu'il atteint un fichier 'use client', sur « Could not find the module … in the React Client Manifest ». La sous-entrée ./public-page, elle, est référencée par app/.fraza/public-pages.server.js que la route importe : le compilateur RSC y arrive donc depuis la route, et les frontières client entrent au manifeste. Détail dans Pages publiques.

Déclare aussi requiresCore dans le manifeste : sur un core trop ancien pour générer ce manifeste, personne n'enregistre la page et elle répond 404 sans rien dire.

Contenu public à la racine

Pour servir du contenu directement sous /<slug>, voir Contenu public et templates.

Médias, SEO, blocs

Migrations

Le module possède ses tables et leur cycle de vie.

// src/db.server.js
import { query, tableExists } from '@fraza/core/database';

const TABLES = [
  { name: 'fraza_blog_posts', sql: `CREATE TABLE fraza_blog_posts (…)` },
];

export async function createTables() {
  const created = [];
  const skipped = [];
  for (const t of TABLES) {
    if (await tableExists(t.name)) { skipped.push(t.name); continue; }
    await query(t.sql);
    created.push(t.name);
  }
  return { created, skipped };
}

export async function dropTables() {
  for (const t of [...TABLES].reverse()) {
    await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
  }
}

Deux règles :

  • Préfixer les tables par fraza_<module>_, pour éviter toute collision avec le core ou un autre module.
  • createTables doit être idempotent, en testant tableExists. fraza-db init est relancé à chaque ajout de module.

dropTables parcourt la liste à l'envers, pour que les tables dépendantes tombent avant celles dont elles dépendent.

Dépendre d'un autre module

fraza-db init crée les tables du core, puis celles de chaque module activé. Sans autre indication, les modules passent par ordre alphabétique de nom de paquet. Une table qui vise par clé étrangère la table d'un autre module échoue donc dès que son module passe avant l'autre dans l'alphabet.

Le module le déclare dans son manifeste :

export const manifest = {
  name: '@acme/module-rapports',
  requiresModules: ['@acme/module-entreprises', '@acme/module-notifications'],
  // …
};

L'effet est triple :

  • fraza-db init, migrate et status passent chaque module après ceux qu'il requiert, et drop dans l'ordre inverse. L'ordre alphabétique reste la règle partout où rien n'impose autre chose.
  • Au démarrage, initializeFraza lève si un module requis est absent ou désactivé, comme pour un requiresCore non satisfait : sans lui, les requêtes du module échoueraient sur une table manquante.
  • fraza-doctor signale le prérequis manquant, et un cycle entre modules.

Ne pas contourner l'ordre en renommant les modules pour que l'alphabet tombe juste, ni en appelant le createTables d'un autre module depuis le sien : le premier casse au prochain module ajouté, le second crée les tables d'un module qui n'est peut-être pas activé. Un module requis se déclare aussi dans peerDependencies, pour que npm l'installe.

requiresModules est lu à partir de la version du core qui l'introduit : déclarer cette version dans requiresCore du module qui s'en sert.

Faire évoluer le schéma

createTables() crée le schéma initial, rien de plus. Sur une base où la table existe déjà, il court-circuite : une colonne ajoutée après coup n'y arrivera jamais.

Toute évolution passe donc par une migration versionnée, dans src/migrations.js :

// src/migrations.js
export const migrations = [
  {
    id: '001-add-excerpt',
    description: 'Ajoute la colonne excerpt.',
    up: async (ctx) => {
      await ctx.sql(`ALTER TABLE fraza_blog_posts ADD COLUMN excerpt TEXT`);
    },
  },
];
// src/index.js
export { createTables, dropTables } from './db.server.js';
export { migrations } from './migrations.js';

Ne pas glisser un ALTER TABLE … IF NOT EXISTS dans createTables(). C'est ce qui a produit, dans les modules officiels, une accumulation de correctifs rejoués à chaque démarrage pour l'éternité, marqués LEGACY dans le code. Une migration s'applique une fois et cesse.

Les pièges à connaître, notamment l'obligation d'utiliser ctx.tableExists plutôt que la fonction importée, sont dans Écrire une migration.

Déclarer la version de core exigée

export const manifest = {
  name: '@fraza/module-blog',
  version: MODULE_VERSION,
  requiresCore: '0.2.0',
};

requiresCore est un plancher, pas une plage. Il doit correspondre à la version de core qui a introduit la sous-entrée la plus récente utilisée par le module. Le déclarer permet à fraza-update de résoudre un jeu de versions cohérent, et au boot de refuser une combinaison inexécutable avec un message qui nomme le module.

Un module qui expose ./public-page doit impérativement le déclarer. Sur un core qui ne sait pas générer app/.fraza/public-pages.server.js, rien n'enregistre la page : elle répondrait 404 sans le moindre message, ce qui est bien pire qu'un refus au boot.

Le peerDependencies du package.json porte la même contrainte pour npm :

"peerDependencies": { "@fraza/core": ">=0.2.0 <0.3.0" }

En 0.x, la borne haute s'arrête à la mineure suivante : une nouvelle version mineure du core peut contenir un changement cassant. Voir Publication.

Classes Tailwind

Aucune action requise côté module. fraza-modules sync écrit app/.fraza/sources.css dans le projet : une directive @source par module activé, qui pointe vers son dossier dist/ réel. Le styles/globals.css du projet l'importe. Les classes utilisées dans les composants du module sont générées automatiquement, y compris les variantes responsives et les valeurs arbitraires.

C'est vrai quel que soit le nom du paquet (@fraza/module-*, @fraza-pro/module-*, fraza-module-* ou mot-clé fraza-module) et quelle que soit l'installation, registre, file: ou npm link. Une seule condition : les fichiers compilés sont dans dist/. La portée @fraza-pro est réservée aux modules Pro officiels. Voir Styles.

Écrire des jetons, pas des couleurs

Un module s'affiche dans des projets qui ont chacun leur thème. Ses composants écrivent donc les jetons du core (bg-surface, text-fg-muted, border-line, bg-primary text-primary-fg, text-danger), jamais une nuance de palette ni une variante dark: de couleur. Sinon, le module reste noir et blanc au milieu d'un admin passé à la couleur de la marque. Voir Design system.

Le core fournit le même garde-fou que pour lui même, à lancer sur le dist/ du module :

node ../core/scripts/check-colors.js dist

Une couleur choisie par l'utilisateur, stockée en base, est une donnée : elle s'affiche telle quelle, en style inline, et le garde-fou ne la voit pas. Une palette catégorielle qui distingue des types doit être déclarée comme exception dans le script, avec sa raison.

Squelette minimal

@fraza/module-blog/
├── package.json          name: "@fraza/module-blog", main: "./dist/index.js"
├── tsup.config.js
├── README.md             installation, activation, lien vers la documentation
├── scripts/
│   └── sync-version.js       génère src/version.js depuis package.json#version
├── src/
│   ├── index.js              manifest, register, createTables, dropTables
│   ├── version.js            généré — MODULE_VERSION, lu par le manifeste
│   ├── client.js             'use client' — registerPage, registerWidget, registerAdminIcons
│   ├── public-page.js        registerPublicModulePage, lu depuis le graphe RSC
│   ├── db.server.js          createTables / dropTables
│   ├── register-server.js    enregistrements serveur, chargé par register()
│   ├── api.server.js         routes API
│   ├── icons/                une icône par fichier, celles absentes du core
│   ├── admin/
│   │   └── BlogAdminPage.client.js
│   └── public/
│       └── BlogPublicPage.server.js
└── dist/                 généré par le build, c'est ce qui est publié
{
  "main": "./dist/index.js",
  "exports": {
    ".":             { "import": "./dist/index.js" },
    "./client":      { "import": "./dist/client.js" },
    "./public-page": { "import": "./dist/public-page.js" }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup",
    "sync:version": "node ./scripts/sync-version.js",
    "prepublishOnly": "node ./scripts/sync-version.js --check && npm run build",
    "audit": "npm audit --omit=dev --omit=peer"
  },
  "peerDependencies": {
    "@fraza/core": ">=0.2.0 <0.3.0",
    "next": ">=14.0.0",
    "react": "^19.0.0"
  }
}

scripts/sync-version.js est vendu à l'identique dans tous les dépôts de l'écosystème : copie-le depuis @fraza/core. Le --check en prepublishOnly empêche de publier un src/version.js périmé.

Où vit la documentation d'un module

La documentation d'usage d'un module officiel vit dans le core, sous docs/06-modules/. Le README.md du module reste court : installation, activation, et un lien vers cette section.

La raison tient au point de lecture. Le champ files d'un module ne publie que dist, README.md et LICENSE : un dossier docs/ dans le dépôt du module n'atteint jamais le projet qui l'installe. La documentation du core, elle, est livrée sous node_modules/@fraza/core/dist/docs/. Un projet y trouve donc la plateforme et ses modules au même endroit, hors ligne.

Les conventions de code, de rédaction, de commit et de publication sont communes à tout l'écosystème et vivent dans Contribution. Elles ne se recopient pas dans le dépôt d'un module.

Pour un module tiers, publié hors de cet écosystème, le README.md reste le seul point d'entrée : le rendre autonome.

Dépendances d'un module

Trois règles, qui déterminent combien de travail coûtera la prochaine alerte de sécurité.

Tout ce que le site fournit déjà va en peerDependencies. @fraza/core, next et react bien sûr, mais aussi une librairie lourde que Next installe de son côté. @fraza/module-medias déclare sharp en peer pour cette raison : le site n'en garde qu'une copie, dans une version qu'il contrôle. Un module qui la mettrait en dependencies en imposerait une seconde, à corriger séparément.

Ce qui reste en dependencies est la vraie surface du module. C'est ce que npm run audit inspecte, et le seul endroit où une alerte se corrige dans le dépôt du module. Trois des quatre modules officiels ont zéro dependencies.

Aucun bloc overrides. npm ne l'honore que depuis le package.json racine du projet installé : un overrides publié dans un module est ignoré par les sites. Le bloc unique de l'écosystème vit dans @fraza/start. Voir Sécurité.

Build avant publication

Tout module est pré-compilé avant publication, comme @fraza/core. Le build tsup avec bundle: false transforme le JSX, préserve les directives 'use client' en tête des fichiers compilés, et garde un fichier de sortie par fichier d'entrée pour respecter les frontières RSC quand le projet consommateur bundle le module.

C'est ce qui permet au manifeste statique de faire un simple import * as et de laisser Turbopack composer le reste : aucune transformation runtime n'est requise côté consommateur.

// tsup.config.js
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/**/*.js', 'src/**/*.jsx'],
  format: ['esm'],
  outDir: 'dist',
  bundle: false,
  splitting: false,
  clean: true,
  external: ['react', 'react-dom', 'next', '@fraza/core'],
  // Le JSX se règle ici, pas à la racine : tsup ne transmet pas à esbuild
  // les options qu'il ne connaît pas, il les ignore sans erreur.
  esbuildOptions(o) {
    o.loader = { '.js': 'jsx', '.jsx': 'jsx' };
    o.jsx = 'automatic';
    o.outbase = 'src';
  },
});

jsx et outbase passent par esbuildOptions. Posés à la racine de defineConfig, ils sont ignorés sans avertissement : esbuild compile alors le JSX en React.createElement sans importer React, et le module ne casse qu'au rendu, sur « React is not defined ». C'est la configuration du core et des modules officiels.

Vérifier la compilation

Avant npm publish, ouvrir dist/ et confirmer qu'aucun fichier ne contient de JSX brut : chercher un < suivi d'une majuscule. Tous les composants doivent apparaître sous forme d'appels jsx(...) ou jsxs(...), importés de react/jsx-runtime. Un React.createElement(...) signale que l'option jsx n'a pas été prise en compte : voir la configuration ci-dessus.

Vérifier aussi que les fichiers issus de .client.js commencent bien par 'use client'.

Publier

Même procédure que le core. Voir Publication.

Sur cette page