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:
- Un descrittore — un piccolo oggetto che descrive il plugin (id, versione, capacità, storage, dove trovare l’entry runtime). Importato da
astro.config.mjsal momento della build. - 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
-
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’entrypointdel descrittore. Il bundler costruisce entrambi i file indist/. -
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 campoformatè predefinito su"native".entrypointè uno specificatore di modulo, non un percorso di file. Usa la stessa stringa che passeresti aimport— di solito"<package-name>/sandbox". Il nome del package può essere scoped (@my-org/plugin-hello); l’iddel 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 unidnon scoped comeplugin-hellocon un nome di package npm scoped inentrypoint.- 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
awaitdi livello superiore, nessunfetcha 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 }. Nessunid, nessunaversion, nessunacapabilities— 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.routeCtxha{ input, request, requestMeta };ctxè lo stessoPluginContextche ottieni negli hook. Le route sono raggiungibili a/_emdash/api/plugins/<plugin-id>/<route-name>. ctx.storage.eventsfunziona 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 conget,set,deleteelist(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
- Hook — l’insieme completo di eventi a cui puoi reagire
- Route API — validazione input, route pubbliche, gestione errori
- Storage e KV — opzioni di query, indici, operazioni batch
- Capacità e sicurezza — accesso ai contenuti, richieste di rete, whitelist di host
- Bundling e pubblicazione — quando sei pronto per pubblicare sul marketplace