Votre premier plugin sandboxé

Sur cette page

Ce guide vous accompagne dans la création d’un plugin sandboxé minimal de zéro — un plugin qui enregistre chaque sauvegarde de contenu et expose une seule route API. À la fin, vous aurez un plugin qui s’exécute dans un runtime isolé via le runner sandbox configuré. Le même code de plugin peut également s’exécuter en processus si l’opérateur du site choisit de le déplacer de sandboxed: [] vers plugins: [] — par exemple sur des plateformes sans runner sandbox disponible.

Si vous n’avez pas encore décidé si vous voulez un plugin sandboxé ou natif, lisez d’abord Choisir un format de plugin.

Deux fichiers

Chaque plugin sandboxé contient deux éléments :

  1. Un descripteur — un petit objet décrivant le plugin (id, version, capacités, stockage, où trouver l’entrée runtime). Importé par astro.config.mjs au moment de la compilation.
  2. Une entrée sandbox — le code runtime : hooks, routes, accès au stockage. Chargé dans le runtime sandbox au moment de la requête.

Les deux fichiers vivent dans le même package et s’exécutent dans des environnements complètement différents. Le descripteur ne voit jamais le contexte runtime ; l’entrée ne voit jamais astro.config.mjs.

my-plugin/
├── src/
│   ├── index.ts          # Descripteur — s'exécute dans Vite au moment de la compilation
│   └── sandbox-entry.ts  # Hooks, routes, stockage — s'exécute dans le runtime sandbox
├── package.json
└── tsconfig.json

Configurer le package

  1. Créez un nouveau répertoire et initialisez-le en tant que package de module TypeScript ES.

    {
    	"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/sandbox-entry.mjs"
    	},
    	"files": ["dist"],
    	"scripts": {
    		"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean"
    	},
    	"peerDependencies": {
    		"emdash": "*"
    	},
    	"devDependencies": {
    		"emdash": "*",
    		"tsdown": "^0.6.0",
    		"typescript": "^5.5.0"
    	}
    }

    L’export "./sandbox" est ce vers quoi pointera l’entrypoint du descripteur. Le bundler construit les deux fichiers dans dist/.

  2. Ajoutez un tsconfig.json :

    {
    	"compilerOptions": {
    		"target": "ES2022",
    		"module": "preserve",
    		"moduleResolution": "bundler",
    		"strict": true,
    		"esModuleInterop": true,
    		"declaration": true,
    		"outDir": "./dist",
    		"rootDir": "./src"
    	},
    	"include": ["src/**/*"],
    	"exclude": ["node_modules", "dist"]
    }

Écrire le descripteur

Le descripteur est une fonction factory qui retourne un PluginDescriptor. Il s’exécute dans Vite au moment de la compilation, ce qui signifie qu’il doit être sans effet de bord et ne peut pas utiliser d’API runtime (fetch, la base de données, les variables d’environnement — aucune de celles-ci n’existe encore).

import type { PluginDescriptor } from "emdash";

export function helloPlugin(): PluginDescriptor {
	return {
		id: "plugin-hello",
		version: "0.1.0",
		format: "standard",
		entrypoint: "@my-org/plugin-hello/sandbox",

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

Quelques détails importants :

  • format: "standard" est requis. Sans lui, EmDash traite le package comme un plugin natif et cherche une forme différente. Le champ format est par défaut "native".
  • entrypoint est un spécificateur de module, pas un chemin de fichier. Utilisez la même chaîne que vous passeriez à import — généralement "<package-name>/sandbox". Le nom du package peut être scopé (@my-org/plugin-hello) ; l’id du plugin ne le peut pas.
  • id est un slug sûr pour les URL, pas le nom du package npm. Il doit correspondre à /^[a-z][a-z0-9_-]*$/ — commencer par une lettre minuscule, puis des lettres, des chiffres, des tirets ou des underscores. L’id est utilisé à la fois comme un seul segment de chemin dans les URL de route de plugin (/_emdash/api/plugins/<id>/...) et comme partie des identifiants SQL générés pour les index de stockage de plugin, donc @, /, les chiffres en tête et les lettres majuscules échouent tous au runtime. Associez un id non scopé comme plugin-hello avec un nom de package npm scopé dans entrypoint.
  • Les capacités, allowedHosts et le stockage vivent sur le descripteur. L’entrée sandbox ne les déclare pas — elle ne peut utiliser que ce que le descripteur permet.
  • Ne mettez pas de logique runtime ici. Pas de await de niveau supérieur, pas de fetch au niveau du module, pas de lecture de fichiers. Le descripteur est des métadonnées.

Écrire l’entrée sandbox

Le côté runtime. Ce fichier est chargé dans le runtime sandbox au moment de la requête, sans accès à quoi que ce soit sauf ce que fournit ctx.

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

interface ContentSaveEvent {
	collection: string;
	content: { id: string };
	isNew: boolean;
}

export default definePlugin({
	hooks: {
		"content:afterSave": {
			handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
				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: PluginContext) => {
				const result = await ctx.storage.events.query({ limit: 10 });
				return { events: result.items };
			},
		},
	},
});

Choses à savoir :

  • definePlugin() dans l’entrée sandbox ne prend que { hooks, routes }. Pas d’id, pas de version, pas de capabilities — ceux-ci viennent du descripteur. EmDash lève une erreur au moment de la compilation si vous essayez de les passer ici.
  • Les gestionnaires de hooks prennent (event, ctx). La forme de l’événement dépend du nom du hook ; voir la Référence des hooks.
  • Les gestionnaires de routes prennent (routeCtx, ctx) — deux arguments. routeCtx a { input, request, requestMeta } ; ctx est le même PluginContext que vous obtenez dans les hooks. Les routes sont accessibles à /_emdash/api/plugins/<plugin-id>/<route-name>.
  • ctx.storage.events fonctionne car events a été déclaré sur le descripteur. Accéder à une collection non déclarée lève une erreur.
  • ctx.kv est toujours disponible — un magasin clé-valeur par plugin avec get, set, delete et list(prefix).

Enregistrer le plugin

Dans le astro.config.mjs de votre site, importez la factory de descripteur et passez-la à l’intégration EmDash. Les plugins sandboxés vont dans sandboxed: [] ; les plugins en processus vont dans plugins: []. Un plugin au format standard fonctionne dans les deux — commencez avec sandboxed.

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

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

sandboxRunner est la pièce pluggable. L’exemple ci-dessus utilise sandbox() de @emdash-cms/cloudflare, qui est le runner sandbox que la plupart des sites utilisent aujourd’hui. Les runners pour d’autres plateformes sont en développement. Si aucun runner n’est configuré (ou si le runner configuré signale comme non disponible sur la plateforme actuelle), les plugins sandboxed: [] sont ignorés au démarrage — pour exécuter le même plugin en processus, déplacez-le de sandboxed: [] vers plugins: [].

Compiler et exécuter

Depuis le répertoire du plugin :

pnpm build

Dans le répertoire du site, liez ou installez le plugin (pnpm add @my-org/plugin-hello ou un lien d’espace de travail), puis démarrez le serveur de développement. Vous devriez voir [hello] Content saved … dans les logs la prochaine fois que vous sauvegarderez un élément de contenu dans l’admin, et GET /_emdash/api/plugins/plugin-hello/recent devrait retourner les dix derniers événements de sauvegarde.

À lire ensuite

  • Hooks — l’ensemble complet des événements auxquels vous pouvez réagir
  • Routes API — validation d’entrée, routes publiques, gestion des erreurs
  • Stockage et KV — options de requête, index, opérations par lots
  • Capacités et sécurité — accès au contenu, requêtes réseau, listes blanches d’hôtes
  • Bundling et publication — quand vous êtes prêt à livrer sur le marketplace