Il tuo primo plugin sandboxed

In questa pagina

Questa guida ti accompagna nella costruzione di un plugin sandboxed minimale da zero — un plugin che registra ogni salvataggio di contenuto ed espone una singola route API. Alla fine avrai un plugin che viene eseguito in un runtime isolato tramite il sandbox runner configurato. Lo stesso codice del plugin può anche essere eseguito in-process se l’operatore del sito sceglie di spostarlo da sandboxed: [] a plugins: [] — ad esempio su piattaforme senza un sandbox runner disponibile.

Se non hai ancora deciso se vuoi un plugin sandboxed o nativo, leggi prima Scegliere un formato di plugin.

Due file

Ogni plugin sandboxed è composto da due pezzi:

  1. Un descrittore — un piccolo oggetto che descrive il plugin (id, versione, capacità, storage, dove trovare l’entry runtime). Importato da astro.config.mjs al momento della build.
  2. Un entry sandbox — il codice runtime: hook, route, accesso allo storage. Caricato nel runtime sandbox al momento della richiesta.

I due file vivono nello stesso package e vengono eseguiti in ambienti completamente diversi. Il descrittore non vede mai il contesto runtime; l’entry non vede mai astro.config.mjs.

my-plugin/
├── src/
│   ├── index.ts          # Descrittore — eseguito in Vite al momento della build
│   └── sandbox-entry.ts  # Hook, route, storage — eseguito nel runtime sandbox
├── package.json
└── tsconfig.json

Configurare il package

  1. Crea una nuova directory e inizializzala come package di modulo 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" è ciò a cui punterà l’entrypoint del descrittore. Il bundler costruisce entrambi i file in dist/.

  2. Aggiungi 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"]
    }

Scrivere il descrittore

Il descrittore è una funzione factory che restituisce un PluginDescriptor. Viene eseguito in Vite al momento della build, il che significa che deve essere privo di effetti collaterali e non può usare API runtime (fetch, il database, variabili d’ambiente — nessuna di queste esiste ancora).

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"] },
		},
	};
}

Alcuni dettagli importanti:

  • format: "standard" è obbligatorio. Senza di esso, EmDash tratta il package come un plugin nativo e cerca una forma diversa. Il campo format è predefinito su "native".
  • entrypoint è uno specificatore di modulo, non un percorso di file. Usa la stessa stringa che passeresti a import — di solito "<package-name>/sandbox". Il nome del package può essere scoped (@my-org/plugin-hello); l’id del plugin non può esserlo.
  • id è uno slug sicuro per URL, non il nome del package npm. Deve corrispondere a /^[a-z][a-z0-9_-]*$/ — iniziare con una lettera minuscola, poi lettere, cifre, trattini o underscore. L’id viene usato sia come singolo segmento di percorso negli URL delle route dei plugin (/_emdash/api/plugins/<id>/...) sia come parte degli identificatori SQL generati per gli indici di storage dei plugin, quindi @, /, cifre iniziali e lettere maiuscole falliscono tutti al runtime. Accoppia un id non scoped come plugin-hello con un nome di package npm scoped in entrypoint.
  • Le capacità, allowedHosts e lo storage risiedono nel descrittore. L’entry sandbox non li dichiara — può solo usare ciò che il descrittore permette.
  • Non mettere logica runtime qui. Nessun await di livello superiore, nessun fetch a livello di modulo, nessuna lettura di file. Il descrittore è metadata.

Scrivere l’entry sandbox

Il lato runtime. Questo file viene caricato nel runtime sandbox al momento della richiesta, senza accesso a nient’altro che ciò che fornisce 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 };
			},
		},
	},
});

Cose da sapere:

  • definePlugin() nell’entry sandbox accetta solo { hooks, routes }. Nessun id, nessuna version, nessuna capabilities — queste provengono dal descrittore. EmDash genera un errore al momento della build se provi a passarle qui.
  • Gli handler degli hook accettano (event, ctx). La forma dell’evento dipende dal nome dell’hook; vedi il Riferimento degli hook.
  • Gli handler delle route accettano (routeCtx, ctx) — due argomenti. routeCtx ha { input, request, requestMeta }; ctx è lo stesso PluginContext che ottieni negli hook. Le route sono raggiungibili a /_emdash/api/plugins/<plugin-id>/<route-name>.
  • ctx.storage.events funziona perché events è stato dichiarato nel descrittore. Accedere a una collection non dichiarata genera un errore.
  • ctx.kv è sempre disponibile — un key-value store per plugin con get, set, delete e list(prefix).

Registrare il plugin

Nel astro.config.mjs del tuo sito, importa la factory del descrittore e passala all’integrazione EmDash. I plugin sandboxed vanno in sandboxed: []; i plugin in-process vanno in plugins: []. Un plugin in formato standard funziona in entrambi — inizia 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 è la parte pluggable. L’esempio sopra usa sandbox() da @emdash-cms/cloudflare, che è il sandbox runner che la maggior parte dei siti usa oggi. I runner per altre piattaforme sono in sviluppo. Se non è configurato nessun runner (o il runner configurato riporta come non disponibile sulla piattaforma corrente), i plugin sandboxed: [] vengono saltati all’avvio — per eseguire lo stesso plugin in-process, spostalo da sandboxed: [] a plugins: [].

Build ed esecuzione

Dalla directory del plugin:

pnpm build

Nella directory del sito, collega o installa il plugin (pnpm add @my-org/plugin-hello o un link del workspace), poi avvia il server di sviluppo. Dovresti vedere [hello] Content saved … nei log la prossima volta che salvi un contenuto nell’admin, e GET /_emdash/api/plugins/plugin-hello/recent dovrebbe restituire gli ultimi dieci eventi di salvataggio.

Cosa leggere dopo