Esta guía te lleva a través de la construcción de un plugin sandboxed mínimo desde cero — un plugin que registra cada guardado de contenido y expone una única ruta API. Al final tendrás un plugin que se ejecuta en un runtime aislado a través del sandbox runner configurado. El mismo código del plugin también puede ejecutarse en proceso si el operador del sitio decide moverlo de sandboxed: [] a plugins: [] — por ejemplo, en plataformas sin un sandbox runner disponible.
Si aún no has decidido si quieres un plugin sandboxed o nativo, lee primero Elegir un formato de plugin.
Dos archivos
Cada plugin sandboxed consta de dos piezas:
- Un descriptor — un pequeño objeto que describe el plugin (id, versión, capacidades, almacenamiento, dónde encontrar la entrada de runtime). Importado por
astro.config.mjsen tiempo de compilación. - Una entrada sandbox — el código de runtime: hooks, rutas, acceso al almacenamiento. Cargado en el runtime sandbox en tiempo de solicitud.
Los dos archivos viven en el mismo paquete y se ejecutan en entornos completamente diferentes. El descriptor nunca ve el contexto de runtime; la entrada nunca ve astro.config.mjs.
my-plugin/
├── src/
│ ├── index.ts # Descriptor — se ejecuta en Vite en tiempo de compilación
│ └── sandbox-entry.ts # Hooks, rutas, almacenamiento — se ejecuta en el runtime sandbox
├── package.json
└── tsconfig.json
Configurar el paquete
-
Crea un nuevo directorio e inicialízalo como un paquete de módulo 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" } }La exportación
"./sandbox"es a lo que apuntará elentrypointdel descriptor. El bundler construye ambos archivos endist/. -
Agrega 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"] }
Escribir el descriptor
El descriptor es una función factory que devuelve un PluginDescriptor. Se ejecuta en Vite en tiempo de compilación, lo que significa que debe estar libre de efectos secundarios y no puede usar ninguna API de runtime (fetch, la base de datos, variables de entorno — ninguna de estas existe todavía).
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"] },
},
};
}
Algunos detalles importantes:
format: "standard"es obligatorio. Sin él, EmDash trata el paquete como un plugin nativo y busca una forma diferente. El campoformates"native"por defecto.entrypointes un especificador de módulo, no una ruta de archivo. Usa la misma cadena que pasarías aimport— normalmente"<package-name>/sandbox". El nombre del paquete puede tener ámbito (@my-org/plugin-hello); eliddel plugin no puede tenerlo.ides un slug seguro para URL, no el nombre del paquete npm. Debe coincidir con/^[a-z][a-z0-9_-]*$/— comenzar con una letra minúscula, luego letras, dígitos, guiones o guiones bajos. El id se usa tanto como un único segmento de ruta en las URL de ruta del plugin (/_emdash/api/plugins/<id>/...) como parte de los identificadores SQL generados para los índices de almacenamiento del plugin, por lo que@,/, dígitos iniciales y letras mayúsculas fallan todos en tiempo de ejecución. Empareja unidsin ámbito comoplugin-hellocon un nombre de paquete npm con ámbito enentrypoint.- Las capacidades, allowedHosts y el almacenamiento residen en el descriptor. La entrada sandbox no los declara — solo puede usar lo que el descriptor permite.
- No pongas lógica de runtime aquí. Sin
awaitde nivel superior, sinfetcha nivel de módulo, sin lectura de archivos. El descriptor son metadatos.
Escribir la entrada sandbox
El lado del runtime. Este archivo se carga en el runtime sandbox en tiempo de solicitud, sin acceso a nada excepto lo que proporciona 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 };
},
},
},
});
Cosas que vale la pena saber:
definePlugin()en la entrada sandbox solo acepta{ hooks, routes }. Sinid, sinversion, sincapabilities— estos vienen del descriptor. EmDash lanza un error en tiempo de compilación si intentas pasarlos aquí.- Los manejadores de hooks aceptan
(event, ctx). La forma del evento depende del nombre del hook; consulta la Referencia de hooks. - Los manejadores de rutas aceptan
(routeCtx, ctx)— dos argumentos.routeCtxtiene{ input, request, requestMeta };ctxes el mismoPluginContextque obtienes en los hooks. Las rutas son accesibles en/_emdash/api/plugins/<plugin-id>/<route-name>. ctx.storage.eventsfunciona porqueeventsse declaró en el descriptor. Acceder a una colección no declarada lanza un error.ctx.kvsiempre está disponible — un almacén de clave-valor por plugin conget,set,deleteylist(prefix).
Registrar el plugin
En el astro.config.mjs de tu sitio, importa la factory del descriptor y pásala a la integración EmDash. Los plugins sandboxed van en sandboxed: []; los plugins en proceso van en plugins: []. Un plugin de formato estándar funciona en ambos — comienza con 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 es la pieza conectable. El ejemplo anterior usa sandbox() de @emdash-cms/cloudflare, que es el sandbox runner que la mayoría de los sitios usan hoy. Los runners para otras plataformas están en desarrollo. Si no hay un runner configurado (o el runner configurado informa como no disponible en la plataforma actual), los plugins sandboxed: [] se omiten al inicio — para ejecutar el mismo plugin en proceso, muévelo de sandboxed: [] a plugins: [].
Construir y ejecutar
Desde el directorio del plugin:
pnpm build
En el directorio del sitio, enlaza o instala el plugin (pnpm add @my-org/plugin-hello o un enlace de espacio de trabajo), luego inicia el servidor de desarrollo. Deberías ver [hello] Content saved … en los logs la próxima vez que guardes un contenido en el admin, y GET /_emdash/api/plugins/plugin-hello/recent debería devolver los últimos diez eventos de guardado.
Qué leer a continuación
- Hooks — el conjunto completo de eventos a los que puedes reaccionar
- Rutas API — validación de entrada, rutas públicas, manejo de errores
- Almacenamiento y KV — opciones de consulta, índices, operaciones por lotes
- Capacidades y seguridad — acceso a contenido, solicitudes de red, listas blancas de hosts
- Empaquetado y publicación — cuando estés listo para publicar en el marketplace