Votre premier plugin sandboxed

Sur cette page

Ce guide construit un plugin sandboxed minimal à partir de zéro. Le plugin enregistre chaque sauvegarde de contenu et expose une seule route API. Il s’exécute dans un runtime isolé fourni par le sandbox runner configuré. Le même code s’exécute également en processus lorsqu’un opérateur de site le déplace de sandboxed: [] vers plugins: [], par exemple sur une plateforme sans sandbox runner.

Si vous n’avez pas encore décidé entre sandboxed et native, lisez d’abord Choisir un format de plugin.

Deux pièces

Un plugin sandboxed est :

  1. emdash-plugin.jsonc — un manifest édité manuellement : identité, le contrat de confiance (capabilities, hosts, storage), et champs de profil. Pas de code.
  2. src/plugin.ts — le runtime : hooks et routes. Imports de types uniquement depuis emdash/plugin ; pas d’import runtime de emdash.

emdash-plugin build lit les deux et émet les artefacts dist/ qu’un site consomme.

L’exemple suivant montre la disposition des fichiers d’un plugin complet :

my-plugin/
├── emdash-plugin.jsonc   # Identité + contrat de confiance + profil
├── src/
│   └── plugin.ts         # Hooks, routes — s'exécute dans le runtime sandbox
├── package.json
└── tsconfig.json

Configurer le package

  1. Créez le répertoire et un package.json. Le build est emdash-plugin build ; il n’y a pas d’invocation tsdown à écrire.

    {
    	"name": "@my-org/plugin-hello",
    	"version": "0.1.0",
    	"type": "module",
    	"main": "dist/index.mjs",
    	"exports": {
    		".": {
    			"import": "./dist/index.mjs",
    			"types": "./dist/index.d.mts"
    		},
    		"./sandbox": "./dist/plugin.mjs"
    	},
    	"files": ["dist", "emdash-plugin.jsonc"],
    	"scripts": {
    		"build": "emdash-plugin build",
    		"dev": "emdash-plugin dev"
    	},
    	"peerDependencies": {
    		"emdash": ">=0.13.0"
    	},
    	"devDependencies": {
    		"@emdash-cms/plugin-cli": "0.2.0",
    		"emdash": ">=0.13.0",
    		"typescript": "^5.9.0"
    	}
    }

    "." est le descripteur généré qu’un site importe ; "./sandbox" est le fichier runtime construit. emdash-plugin build génère les deux.

  2. Ajoutez un tsconfig.json :

    {
    	"compilerOptions": {
    		"target": "ES2022",
    		"module": "preserve",
    		"moduleResolution": "bundler",
    		"strict": true,
    		"esModuleInterop": true,
    		"verbatimModuleSyntax": true,
    		"skipLibCheck": true,
    		"types": []
    	},
    	"include": ["src/**/*"],
    	"exclude": ["node_modules"]
    }

Écrire le manifest

emdash-plugin.jsonc porte l’identité du plugin (slug), son contrat de confiance (capabilities, allowedHosts, storage), les champs de profil, et le publisher pin.

L’exemple suivant montre un manifest complet pour le plugin hello :

{
	"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",

	"slug": "plugin-hello",
	"publisher": "did:plc:abc123def456", // your Atmosphere account DID

	"license": "MIT",
	"author": { "name": "Jane Doe", "url": "https://example.com" },
	"security": { "email": "security@example.com" },

	"capabilities": [],
	"allowedHosts": [],
	"storage": { "events": { "indexes": ["timestamp"] } }
}

Notes sur ce manifest :

  • slug est un identifiant sécurisé pour URL, pas le nom du package npm. /^[a-z][a-z0-9_-]*$/, max 64 caractères. C’est un segment de chemin unique dans les URLs de routes du plugin (/_emdash/api/plugins/<slug>/...) et fait partie des identifiants SQL générés pour les index de storage, donc @, /, les chiffres initiaux, et les majuscules échouent. Associez un slug sans scope (plugin-hello) avec un nom de package npm scopé.
  • storage déclare les collections à l’avance. ctx.storage.events fonctionne au runtime uniquement parce que events est déclaré ici. Accéder à une collection non déclarée lève une erreur.
  • version est omise. Le build la lit depuis package.json pour qu’il y ait une seule source de vérité. Voir la référence du manifest.
  • Le contrat de confiance est un consentement. Modifier capabilities, allowedHosts, ou storage ultérieurement nécessite une mise à jour de version — les sites installés ont consenti à l’ancien contrat.

Écrire le runtime

src/plugin.ts exporte par défaut un objet simple annoté avec satisfies SandboxedPlugin. emdash/plugin fournit uniquement des types, donc un plugin sandboxed n’a pas de dépendance runtime sur emdash.

L’exemple suivant enregistre chaque sauvegarde de contenu dans le storage du plugin et expose une route recent qui renvoie les dix dernières sauvegardes :

import type { SandboxedPlugin } from "emdash/plugin";

export default {
	hooks: {
		"content:afterSave": {
			handler: async (event, ctx) => {
				ctx.log.info("Content saved", {
					collection: event.collection,
					id: event.content.id,
				});

				await ctx.storage.events.put(`save-${Date.now()}`, {
					timestamp: new Date().toISOString(),
					collection: event.collection,
					contentId: event.content.id,
				});
			},
		},
	},

	routes: {
		recent: {
			handler: async (_routeCtx, ctx) => {
				const result = await ctx.storage.events.query({ limit: 10 });
				return { events: result.items };
			},
		},
	},
} satisfies SandboxedPlugin;

Notes sur le fichier runtime :

  • satisfies SandboxedPlugin type tout. Il infère event depuis le nom du hook (avec le type d’événement canonique complet) et ctx comme PluginContext, donc les handlers n’ont pas besoin d’annotations de paramètres. Une clé de hook mal tapée comme "content:afterSav" est une erreur de compilation.
  • Les handlers de hooks prennent (event, ctx). La forme de l’événement dépend du nom du hook ; voir le guide des Hooks.
  • Les handlers de routes prennent (routeCtx, ctx) — deux arguments. routeCtx est { input, request, requestMeta? } ; ctx est le même PluginContext. Les routes sont accessibles à /_emdash/api/plugins/<slug>/<route-name>.
  • ctx.storage.events fonctionne parce que events est déclaré dans le manifest.
  • ctx.kv est toujours disponible — un key-value store par plugin avec get, set, delete, list(prefix).

Enregistrer le plugin

Dans le astro.config.mjs du site, importez l’export par défaut du plugin et passez-le. Les plugins sandboxed vont dans sandboxed: [] ; les plugins en processus vont dans plugins: []. Un plugin sandboxed fonctionne dans les deux. L’exemple ci-dessous utilise sandboxed: :

import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sandbox } from "@emdash-cms/cloudflare";
import hello from "@my-org/plugin-hello";

export default defineConfig({
	integrations: [
		emdash({
			sandboxed: [hello],
			sandboxRunner: sandbox(),
		}),
	],
});

sandboxRunner est la pièce interchangeable. L’exemple utilise sandbox() de @emdash-cms/cloudflare, le runner que la plupart des sites utilisent aujourd’hui. Si aucun runner n’est configuré (ou si le runner configuré n’est pas disponible sur la plateforme actuelle), les plugins sandboxed: [] sont ignorés au démarrage — déplacez le plugin dans plugins: [] pour l’exécuter en processus.

Construire et exécuter

Depuis le répertoire du plugin :

emdash-plugin validate   # vérifier le schéma du manifest d'abord
emdash-plugin build      # émettre dist/

Pour une boucle d’édition, exécutez emdash-plugin dev (reconstruit à la sauvegarde, garde le dernier bon dist/ en cas d’échec du build). Dans le site, installez ou liez le plugin (pnpm add file:../plugin-hello ou un lien workspace) et démarrez le serveur dev. Sauvegardez un contenu dans l’admin et vous devriez voir Content saved … dans les logs ; GET /_emdash/api/plugins/plugin-hello/recent renvoie les dix derniers événements de sauvegarde.

Que lire ensuite