Tu primer plugin sandboxed

En esta página

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:

  1. 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.mjs en tiempo de compilación.
  2. 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

  1. 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á el entrypoint del descriptor. El bundler construye ambos archivos en dist/.

  2. 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 campo format es "native" por defecto.
  • entrypoint es un especificador de módulo, no una ruta de archivo. Usa la misma cadena que pasarías a import — normalmente "<package-name>/sandbox". El nombre del paquete puede tener ámbito (@my-org/plugin-hello); el id del plugin no puede tenerlo.
  • id es 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 un id sin ámbito como plugin-hello con un nombre de paquete npm con ámbito en entrypoint.
  • 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 await de nivel superior, sin fetch a 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 }. Sin id, sin version, sin capabilities — 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. routeCtx tiene { input, request, requestMeta }; ctx es el mismo PluginContext que obtienes en los hooks. Las rutas son accesibles en /_emdash/api/plugins/<plugin-id>/<route-name>.
  • ctx.storage.events funciona porque events se declaró en el descriptor. Acceder a una colección no declarada lanza un error.
  • ctx.kv siempre está disponible — un almacén de clave-valor por plugin con get, set, delete y list(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