Seu primeiro plugin sandboxed

Nesta página

Este guia constrói um plugin sandboxed mínimo do zero. O plugin registra cada salvamento de conteúdo e expõe uma única rota de API. Ele é executado em um runtime isolado fornecido pelo sandbox runner configurado. O mesmo código também é executado em processo quando um operador do site o move de sandboxed: [] para plugins: [], por exemplo em uma plataforma sem sandbox runner.

Se você ainda não decidiu entre sandboxed e native, leia primeiro Escolhendo um formato de plugin.

Duas peças

Um plugin sandboxed é:

  1. emdash-plugin.jsonc — um manifest editado manualmente: identidade, o contrato de confiança (capabilities, hosts, storage), e campos de perfil. Sem código.
  2. src/plugin.ts — o runtime: hooks e routes. Imports apenas de tipos de emdash/plugin; sem import runtime de emdash.

emdash-plugin build lê ambos e emite os artefatos dist/ que um site consome.

O exemplo a seguir mostra o layout de arquivos de um plugin completo:

my-plugin/
├── emdash-plugin.jsonc   # Identidade + contrato de confiança + perfil
├── src/
│   └── plugin.ts         # Hooks, routes — executa no runtime sandbox
├── package.json
└── tsconfig.json

Configurar o pacote

  1. Crie o diretório e um package.json. O build é emdash-plugin build; não há invocação tsdown para escrever.

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

    "." é o descritor gerado que um site importa; "./sandbox" é o arquivo runtime construído. emdash-plugin build gera ambos.

  2. Adicione um tsconfig.json:

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

Escrever o manifest

emdash-plugin.jsonc carrega a identidade do plugin (slug), seu contrato de confiança (capabilities, allowedHosts, storage), campos de perfil, e o publisher pin.

O exemplo a seguir mostra um manifest completo para o 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 é um id seguro para URL, não o nome do pacote npm. /^[a-z][a-z0-9_-]*$/, máx 64 caracteres. É um único segmento de caminho nas URLs de rotas do plugin (/_emdash/api/plugins/<slug>/...) e parte dos identificadores SQL gerados para índices de storage, então @, /, dígitos iniciais e maiúsculas falham. Combine um slug sem escopo (plugin-hello) com um nome de pacote npm com escopo.
  • storage declara coleções antecipadamente. ctx.storage.events funciona em runtime apenas porque events está declarado aqui. Acessar uma coleção não declarada lança um erro.
  • version é omitida. O build a lê de package.json para que haja uma única fonte de verdade. Veja a referência do manifest.
  • O contrato de confiança é consentimento. Alterar capabilities, allowedHosts, ou storage posteriormente requer um bump de versão — sites instalados consentiram com o contrato antigo.

Escrever o runtime

src/plugin.ts exporta por padrão um objeto simples anotado com satisfies SandboxedPlugin. emdash/plugin fornece apenas tipos, então um plugin sandboxed não tem dependência runtime em emdash.

O exemplo a seguir registra cada salvamento de conteúdo no storage do plugin e expõe uma rota recent que retorna os últimos dez salvamentos:

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 o arquivo runtime:

  • satisfies SandboxedPlugin tipifica tudo. Ele infere event do nome do hook (com o tipo de evento canônico completo) e ctx como PluginContext, então os handlers não precisam de anotações de parâmetros. Uma chave de hook com erro de digitação como "content:afterSav" é um erro de compilação.
  • Handlers de hooks recebem (event, ctx). A forma do evento depende do nome do hook; veja o guia de Hooks.
  • Handlers de routes recebem (routeCtx, ctx) — dois argumentos. routeCtx é { input, request, requestMeta? }; ctx é o mesmo PluginContext. Routes são acessíveis em /_emdash/api/plugins/<slug>/<route-name>.
  • ctx.storage.events funciona porque events está declarado no manifest.
  • ctx.kv está sempre disponível — um key-value store por plugin com get, set, delete, list(prefix).

Registrar o plugin

No astro.config.mjs do site, importe a exportação padrão do plugin e passe-a. Plugins sandboxed vão em sandboxed: []; plugins em processo vão em plugins: []. Um plugin sandboxed funciona em ambos. O exemplo abaixo 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 é a peça intercambiável. O exemplo usa sandbox() de @emdash-cms/cloudflare, o runner que a maioria dos sites usa hoje. Se nenhum runner estiver configurado (ou o runner configurado não estiver disponível na plataforma atual), plugins sandboxed: [] são pulados na inicialização — mova o plugin para plugins: [] para executá-lo em processo.

Construir e executar

Do diretório do plugin:

emdash-plugin validate   # verifica o schema do manifest primeiro
emdash-plugin build      # emite dist/

Para um loop de edição, execute emdash-plugin dev (reconstrói ao salvar, mantém o último dist/ bom em um build com falha). No site, instale ou vincule o plugin (pnpm add file:../plugin-hello ou um link de workspace) e inicie o servidor dev. Salve um conteúdo no admin e você deve ver Content saved … nos logs; GET /_emdash/api/plugins/plugin-hello/recent retorna os últimos dez eventos de salvamento.

O que ler a seguir