Tu primer plugin sandboxed

En esta página

Esta guía construye un plugin sandboxed mínimo desde cero. El plugin registra cada guardado de contenido y expone una única ruta API. Se ejecuta en un runtime aislado proporcionado por el sandbox runner configurado. El mismo código también se ejecuta en proceso cuando un operador del sitio lo mueve de sandboxed: [] a plugins: [], por ejemplo en una plataforma sin sandbox runner.

Si aún no has decidido entre sandboxed y native, lee primero Elegir un formato de plugin.

Dos piezas

Un plugin sandboxed es:

  1. emdash-plugin.jsonc — un manifest editado manualmente: identidad, el contrato de confianza (capabilities, hosts, storage), y campos de perfil. Sin código.
  2. src/plugin.ts — el runtime: hooks y routes. Imports solo de tipos desde emdash/plugin; sin import runtime de emdash.

emdash-plugin build lee ambos y emite los artefactos dist/ que un sitio consume.

El siguiente ejemplo muestra el diseño de archivos de un plugin completo:

my-plugin/
├── emdash-plugin.jsonc   # Identidad + contrato de confianza + perfil
├── src/
│   └── plugin.ts         # Hooks, routes — se ejecuta en el runtime sandbox
├── package.json
└── tsconfig.json

Configurar el paquete

  1. Crea el directorio y un package.json. El build es emdash-plugin build; no hay invocación de tsdown que escribir.

    {
    	"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"
    	}
    }

    "." es el descriptor generado que un sitio importa; "./sandbox" es el archivo runtime construido. emdash-plugin build genera ambos.

  2. Añade un tsconfig.json:

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

Escribir el manifest

emdash-plugin.jsonc lleva la identidad del plugin (slug), su contrato de confianza (capabilities, allowedHosts, storage), campos de perfil, y el publisher pin.

El siguiente ejemplo muestra un manifest completo para el 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"] } }
}

Notas sobre este manifest:

  • slug es un id seguro para URL, no el nombre del paquete npm. /^[a-z][a-z0-9_-]*$/, máx 64 caracteres. Es un único segmento de ruta en las URLs de rutas del plugin (/_emdash/api/plugins/<slug>/...) y parte de identificadores SQL generados para índices de storage, así que @, /, dígitos iniciales, y mayúsculas fallan. Combina un slug sin scope (plugin-hello) con un nombre de paquete npm con scope.
  • storage declara colecciones por adelantado. ctx.storage.events funciona en runtime solo porque events está declarado aquí. Acceder a una colección no declarada lanza un error.
  • version se omite. El build lo lee de package.json para que haya una única fuente de verdad. Ver la referencia del manifest.
  • El contrato de confianza es consentimiento. Cambiar capabilities, allowedHosts, o storage más tarde requiere un incremento de versión — los sitios instalados consintieron al contrato antiguo.

Escribir el runtime

src/plugin.ts exporta por defecto un objeto simple anotado con satisfies SandboxedPlugin. emdash/plugin proporciona solo tipos, así que un plugin sandboxed no tiene dependencia runtime en emdash.

El siguiente ejemplo registra cada guardado de contenido en el storage del plugin y expone una ruta recent que devuelve los últimos diez guardados:

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;

Notas sobre el archivo runtime:

  • satisfies SandboxedPlugin tipea todo. Infiere event desde el nombre del hook (con el tipo de evento canónico completo) y ctx como PluginContext, así que los handlers no necesitan anotaciones de parámetros. Una clave de hook mal escrita como "content:afterSav" es un error de compilación.
  • Los handlers de hooks toman (event, ctx). La forma del evento depende del nombre del hook; ver la guía de Hooks.
  • Los handlers de routes toman (routeCtx, ctx) — dos argumentos. routeCtx es { input, request, requestMeta? }; ctx es el mismo PluginContext. Las routes son accesibles en /_emdash/api/plugins/<slug>/<route-name>.
  • ctx.storage.events funciona porque events está declarado en el manifest.
  • ctx.kv siempre está disponible — un key-value store por plugin con get, set, delete, list(prefix).

Registrar el plugin

En el astro.config.mjs del sitio, importa la exportación por defecto del plugin y pásala. Los plugins sandboxed van en sandboxed: []; los plugins en proceso van en plugins: []. Un plugin sandboxed funciona en ambos. El ejemplo siguiente usa 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 es la pieza intercambiable. El ejemplo usa sandbox() de @emdash-cms/cloudflare, el runner que la mayoría de sitios usan hoy. Si no hay runner configurado (o el runner configurado no está disponible en la plataforma actual), los plugins sandboxed: [] se omiten al inicio — mueve el plugin a plugins: [] para ejecutarlo en proceso.

Construir y ejecutar

Desde el directorio del plugin:

emdash-plugin validate   # verifica el schema del manifest primero
emdash-plugin build      # emite dist/

Para un bucle de edición, ejecuta emdash-plugin dev (reconstruye al guardar, mantiene el último dist/ bueno en un build fallido). En el sitio, instala o enlaza el plugin (pnpm add file:../plugin-hello o un enlace de workspace) e inicia el servidor dev. Guarda un contenido en el admin y deberías ver Content saved … en los logs; GET /_emdash/api/plugins/plugin-hello/recent devuelve los últimos diez eventos de guardado.

Qué leer a continuación