Créer des plugins EmDash
Les plugins EmDash étendent le CMS avec des hooks, du stockage, des paramètres, une UI admin, des routes API et des types de blocs Portable Text personnalisés. Tous les plugins sont des packages TypeScript.
Types de plugins
EmDash a deux formats de plugin :
| Type | Format | UI Admin | Où il s'exécute |
|---|---|---|---|
| Standard | definePlugin({ hooks, routes }) |
Block Kit | Isolate sur Cloudflare, in-process ailleurs |
| Native | createPlugin() / definePlugin() avec id+version |
React ou Block Kit | Toujours dans l'isolate de l'hôte |
Standard est le format par défaut. La plupart des plugins devraient l'utiliser. Les plugins standard peuvent être publiés sur la marketplace et fonctionnent en mode de confiance et en mode sandboxé.
Native est une échappatoire pour les plugins qui ont besoin de composants admin React, d'accès direct à la DB ou de composants Astro personnalisés. Les plugins native ne peuvent s'exécuter que dans plugins: [] -- ils ne peuvent pas être sandboxés ni publiés sur la marketplace.
Anatomie d'un plugin
Chaque plugin a deux parties qui s'exécutent dans des contextes différents :
- Descripteur de plugin (
PluginDescriptor) — retourné par la fonction factory dansindex.ts. Déclare les métadonnées (id, version, capacités, stockage). S'exécute au moment de la compilation dans Vite (importé dansastro.config.mjs). Doit être sans effets secondaires. - Définition de plugin (
definePlugin()) — contient la logique d'exécution (hooks, routes). S'exécute au moment de la requête sur le serveur déployé. A accès au contexte complet du plugin (ctx). Se trouve dans un fichier séparé (généralementsandbox-entry.ts).
Ils doivent être dans des points d'entrée séparés car ils s'exécutent dans des environnements complètement différents :
my-plugin/
├── src/
│ ├── index.ts # Factory du descripteur (s'exécute dans Vite au moment de la compilation)
│ ├── sandbox-entry.ts # Définition de plugin avec definePlugin() (s'exécute au moment du déploiement)
│ ├── admin.tsx # Exports de l'UI admin (React) — optionnel, native uniquement
│ └── astro/ # Composants de rendu côté site — optionnel, native uniquement
│ └── index.ts # Doit exporter `blockComponents`
├── package.json
└── tsconfig.json
Plugin minimal (format standard)
Le plugin le plus simple possible -- juste des hooks :
// src/index.ts — factory du descripteur, s'exécute dans Vite au moment de la compilation
import type { PluginDescriptor } from "emdash";
export function myPlugin(): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
};
}
// src/sandbox-entry.ts — définition de plugin, s'exécute au moment de la requête
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": {
handler: async (event: any, ctx: PluginContext) => {
ctx.log.info(`Saved ${event.collection}/${event.content.id}`);
},
},
},
});
Le descripteur est ce qui est importé dans astro.config.mjs. Le champ entrypoint pointe vers le module contenant le default export definePlugin(). Pour les plugins standard, c'est l'export ./sandbox depuis package.json.
Différences clés par rapport au format native :
- Pas de
id,versionoucapabilitiesdansdefinePlugin()-- ceux-ci se trouvent dans le descripteur definePlugin()est une fonction identité qui fournit l'inférence de type- Les handlers de hook utilisent le pattern à deux arguments
(event, ctx) - Les handlers de route utilisent le pattern à deux arguments
(routeCtx, ctx) - Exporté comme
default(pas une fonction factory)
Règles de l'ID de plugin
- Alphanumériques minuscules + tirets uniquement
- Simple (
my-plugin) ou scopé (@my-org/my-plugin) - Unique parmi tous les plugins installés
Enregistrement
Le descripteur est importé dans astro.config.mjs (contexte Vite) :
import { myPlugin } from "@my-org/my-plugin";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()], // s'exécute in-process
// OU
sandboxed: [myPlugin()], // s'exécute en isolate sur Cloudflare
}),
],
});
Les plugins standard fonctionnent dans l'un ou l'autre tableau. Les plugins native ne fonctionnent que dans plugins: [].
Plugins de confiance vs plugins sandboxés
EmDash a deux modes d'exécution. Le code du plugin est identique dans les deux -- seule l'application change.
| De confiance | Sandboxé | |
|---|---|---|
| S'exécute dans | Processus principal | Isolate V8 isolé (Dynamic Worker Loader) |
| Méthode d'installation | astro.config.mjs (changement de code + déploiement) |
UI admin (installation en un clic depuis la marketplace) |
| Capacités | Consultatif (non appliqué) | Appliqué à l'exécution via RPC bridge |
| Limites de ressources | Aucune | CPU 50ms, 10 subrequêtes, 30s temps écoulé, ~128MB mémoire |
| Accès réseau | Sans restriction | Bloqué ; uniquement via ctx.http avec allowedHosts |
| Accès aux données | Accès complet à la base de données | Limité aux capacités déclarées |
| APIs Node.js | Accès complet | Non disponible (isolate V8 uniquement) |
| Disponible sur | Toutes les plateformes | Cloudflare Workers uniquement |
| Idéal pour | Code propriétaire, packages npm vérifiés | Extensions tierces, plugins de la marketplace |
Mode de confiance
Les plugins de confiance sont des packages npm ou des fichiers locaux ajoutés dans astro.config.mjs. Ils s'exécutent in-process avec votre site Astro.
- Les capacités ne sont que de la documentation. Déclarer
["content:read"]documente l'intention mais ne s'applique pas -- le plugin a accès complet au processus. - Installer uniquement depuis des sources de confiance. Un plugin malveillant de confiance a le même accès que le code de votre application.
Mode sandboxé
Les plugins sandboxés s'exécutent dans des isolates V8 isolées sur Cloudflare Workers via Dynamic Worker Loader. Chaque plugin obtient son propre isolate.
- Les capacités s'appliquent. Si un plugin déclare
["content:read"], il ne peut appeler quectx.content.get()etctx.content.list(). Tenterctx.content.create()lève une erreur de permission. - Le réseau est bloqué par défaut. Les appels directs
fetch()échouent. Les plugins doivent utiliserctx.http.fetch(), qui valide contreallowedHosts. - Le stockage est limité. Un plugin ne peut accéder qu'à son propre KV et ses propres collections de stockage.
- L'UI admin utilise Block Kit. Les plugins sandboxés décrivent leur UI comme des blocs JSON -- aucun JavaScript du plugin ne s'exécute dans le navigateur. Voir la référence Block Kit.
- Pas de types de blocs Portable Text. Les blocs PT nécessitent des composants Astro pour le rendu côté site (
componentsEntry), qui sont chargés au moment de la compilation depuis npm. Les plugins sandboxés sont installés à l'exécution et ne peuvent pas livrer de composants. Les blocs PT sont une fonctionnalité réservée aux plugins native. - Les routes fonctionnent. Les routes de plugin standard sont disponibles en mode de confiance et sandboxé via l'RPC
invokeRoute()du sandbox runner.
La sandbox n'est pas disponible sur Node.js. Tous les plugins s'exécutent en mode de confiance sur les plates-formes non-Cloudflare.
Développer pour les deux modes
Écrire le même code. Développer localement en mode de confiance (itération plus rapide, débogage plus facile). Déployer en mode sandboxé en production sans changement de code. Avec le format standard, le même point d'entrée sert les deux modes -- aucune sandbox entry séparée n'est nécessaire.
// src/sandbox-entry.ts -- fonctionne en mode de confiance et sandboxé
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": {
handler: async (event: any, ctx: PluginContext) => {
// De confiance : ctx.http présent car le descripteur déclare network:request
// Sandboxé : ctx.http présent et appliqué via RPC bridge
if (!ctx.http) return;
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
},
});
Contrainte clé pour la compatibilité sandbox : pas de built-ins Node.js (fs, path, child_process, etc.) dans le code backend. Utiliser les APIs Web à la place.
Capacités
Les capacités contrôlent quelles APIs sont disponibles sur ctx. Toujours déclarer ce que votre plugin nécessite -- même en mode de confiance, elles documentent l'intention et sont requises pour l'exécution sandboxée.
| Capacité | Accorde | Propriété ctx |
|---|---|---|
content:read |
ctx.content.get(), ctx.content.list() |
content |
content:write |
ctx.content.create(), ctx.content.update(), ctx.content.delete() |
content |
media:read |
ctx.media.get(), ctx.media.list() |
media |
media:write |
ctx.media.getUploadUrl(), ctx.media.delete() |
media |
network:request |
ctx.http.fetch() (limité à allowedHosts) |
http |
network:request:unrestricted |
ctx.http.fetch() (sans restriction -- pour les URLs configurées par l'utilisateur) |
http |
users:read |
ctx.users.get(), ctx.users.list(), ctx.users.getByEmail() |
users |
email:send |
ctx.email.send() -- envoyer un email via le pipeline |
email |
hooks.email-transport:register |
Peut enregistrer le hook exclusif email:deliver (fournisseur de transport) |
— |
hooks.email-events:register |
Peut enregistrer les hooks email:beforeSend / email:afterSend |
— |
hooks.page-fragments:register |
Peut enregistrer le hook page:fragments (injecter des scripts/styles dans les pages) |
— |
Le stockage (ctx.storage) et KV (ctx.kv) sont toujours disponibles -- aucune capacité nécessaire. Ils sont automatiquement limités au plugin.
Les capacités email sont distinctes :
email:send-- pour les plugins qui consomment email (appelerctx.email.send())hooks.email-transport:register-- pour les plugins qui livrent email (implémenter le transport, ex. Resend, SMTP)hooks.email-events:register-- pour les plugins qui observent ou transforment email (hooks middleware)
// Dans le descripteur (index.ts)
export function myPlugin(): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com", "*.googleapis.com"], // Les wildcards sont supportées
};
}
Quand un plugin marketplace est installé, l'admin voit un dialogue de consentement aux capacités listant ce que le plugin peut accéder. Les utilisateurs doivent approuver avant l'installation.
Publier sur la marketplace
Les plugins standard peuvent être publiés sur la EmDash Marketplace pour une installation en un clic :
emdash plugin bundle --dir packages/plugins/my-plugin # crée .tar.gz
emdash plugin login # authentifier via GitHub
emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz
Voir la Référence de publication pour le format de bundle, la validation et les détails de l'audit de sécurité.
Exports de package
Configurer les exports package.json pour qu'EmDash puisse charger chaque point d'entrée :
{
"name": "@my-org/my-plugin",
"type": "module",
"exports": {
".": "./src/index.ts",
"./sandbox": "./src/sandbox-entry.ts",
"./admin": "./src/admin.tsx"
},
"peerDependencies": {
"emdash": "^0.1.0"
}
}
| Export | Contexte | Objectif |
|---|---|---|
"." |
Vite (compilation) | Factory du descripteur -- importé dans astro.config.mjs |
"./sandbox" |
Serveur (exécution) | definePlugin({ hooks, routes }) -- chargé par entrypoint à l'exécution |
"./admin" |
Navigateur | Composants React pour les pages/widgets admin (plugins native uniquement) |
"./astro" |
Serveur (SSR) | Composants Astro pour le rendu des blocs côté site (plugins native uniquement) |
L'export "." contient le descripteur. L'export "./sandbox" contient l'implémentation. Le champ entrypoint du descripteur pointe vers "./sandbox". N'inclure ./admin et ./astro exports que pour les plugins format native.
Fonctionnalités du plugin
Chaque fonctionnalité est optionnelle. Ajouter uniquement ce que votre plugin nécessite :
| Fonctionnalité | Où | Standard | Native | Objectif |
|---|---|---|---|---|
| Hooks | definePlugin({ hooks }) |
Oui | Oui | Réagir aux événements de contenu/média/cycle de vie |
| Stockage | descripteur storage |
Oui | Oui | Collections de documents avec requêtes indexées |
| KV | ctx.kv dans hooks/routes |
Oui | Oui | Magasin clé-valeur pour l'état interne |
| Routes API | definePlugin({ routes }) |
Oui | Oui | Endpoints REST à /_emdash/api/plugins/<id>/<route> |
| Pages Admin | Route admin Block Kit |
Oui | Oui | Pages admin via Block Kit (blocs JSON) |
| Widgets | Route admin Block Kit |
Oui | Oui | Cartes du tableau de bord via Block Kit |
| Admin React | admin.entry + export React |
Non | Oui | Pages et widgets admin basés sur React (native uniquement) |
| Blocs PT | admin.portableTextBlocks |
Non | Oui | Types de blocs personnalisés dans l'éditeur Portable Text |
| Composants site | componentsEntry |
Non | Oui | Composants Astro pour rendre les blocs sur le site |
Voir les fichiers de référence pour la syntaxe détaillée :
- Référence Hooks -- Tous les types de hooks, signatures, configuration
- Stockage & Paramètres -- Collections, KV, schéma de paramètres
- UI Admin -- Pages, widgets, structure du point d'entrée
- Routes API -- Handlers de route, validation, contexte
- Block Kit -- UI déclarative pour plugins sandboxés (similaire à Slack Block Kit mais pas identique)
- Blocs Portable Text -- Types de blocs personnalisés + rendu frontend
- Publication -- Format de bundle, validation, publication sur la marketplace
Exemple complet : Plugin standard avec hooks, routes et stockage
// src/index.ts — factory du descripteur, s'exécute dans Vite au moment de la compilation
import type { PluginDescriptor } from "emdash";
export function submissionsPlugin(): PluginDescriptor {
return {
id: "submissions",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/plugin-submissions/sandbox",
options: {},
capabilities: ["content:read"],
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
adminPages: [{ path: "/submissions", label: "Submissions", icon: "list" }],
adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
};
}
// src/sandbox-entry.ts — définition de plugin, s'exécute au moment de la requête
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
export default definePlugin({
hooks: {
"plugin:install": {
handler: async (_event: any, ctx: PluginContext) => {
ctx.log.info("Submissions plugin installed");
await ctx.kv.set("settings:maxSubmissions", 1000);
},
},
},
routes: {
submit: {
public: true, // Pas d'authentification requise
handler: async (routeCtx: any, ctx: PluginContext) => {
const { formId, ...data } = routeCtx.input as Record<string, unknown>;
const count = await ctx.storage.submissions.count({ formId });
const max = (await ctx.kv.get<number>("settings:maxSubmissions")) ?? 1000;
if (count >= max) {
return { success: false, error: "Submission limit reached" };
}
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
await ctx.storage.submissions.put(id, {
formId,
data,
status: "pending",
createdAt: new Date().toISOString(),
});
return { success: true, id };
},
},
list: {
handler: async (routeCtx: any, ctx: PluginContext) => {
const url = new URL(routeCtx.request.url);
const limit = Math.max(
1,
Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100),
);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.submissions.query({
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items.map((item: any) => ({ id: item.id, ...item.data })),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
// Handler admin Block Kit pour pages et widgets
admin: {
handler: async (routeCtx: any, ctx: PluginContext) => {
const interaction = routeCtx.input as { type: string; page?: string };
if (interaction.type === "page_load" && interaction.page === "/submissions") {
const result = await ctx.storage.submissions.query({
orderBy: { createdAt: "desc" },
limit: 50,
});
return {
blocks: [
{ type: "header", text: "Submissions" },
{
type: "table",
blockId: "submissions-table",
columns: [
{ key: "formId", label: "Form", format: "text" },
{ key: "status", label: "Status", format: "badge" },
{ key: "createdAt", label: "Date", format: "relative_time" },
],
rows: result.items.map((item: any) => item.data),
},
],
};
}
return { blocks: [] };
},
},
},
});
Contexte du plugin
Tous les hooks et routes reçoivent ctx (PluginContext) :
interface PluginContext {
plugin: { id: string; version: string };
storage: Record<string, StorageCollection>; // Collections déclarées
kv: KVAccess; // Magasin clé-valeur
log: LogAccess; // Logger structuré
content?: ContentAccess; // Si capacité "content:read"
media?: MediaAccess; // Si capacité "media:read"
http?: HttpAccess; // Si capacité "network:request"
users?: UserAccess; // Si capacité "users:read"
cron?: CronAccess; // Toujours disponible -- limité au plugin
email?: EmailAccess; // Si capacité "email:send" ET un fournisseur configuré
}
Les capacités sont déclarées dans le descripteur (pas dans definePlugin() pour le format standard) :
// Dans le descripteur
export function myPlugin(): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
storage: { events: { indexes: ["timestamp"] } },
};
}
Checklist de sortie
Quand créer un plugin format standard, fournir :
src/index.ts-- Factory du descripteur (s'exécute dans Vite au moment de la compilation)src/sandbox-entry.ts--definePlugin({ hooks, routes })comme default export (s'exécute au moment de la requête)package.json-- Avec exports"."(descripteur) et"./sandbox"(implémentation)tsconfig.json-- Config TypeScript standard
Pour les plugins format native (admin React, blocs PT, composants Astro), fournir aussi :
src/admin.tsx-- Point d'entrée admin avec composants Reactsrc/astro/index.ts-- Export de composants de bloc (si blocs PT)