Ihr erstes Sandbox-Plugin

Auf dieser Seite

Dieser Leitfaden führt Sie durch die Erstellung eines minimalen Sandbox-Plugins von Grund auf — ein Plugin, das jedes Speichern von Inhalten protokolliert und eine einzelne API-Route bereitstellt. Am Ende haben Sie ein Plugin, das über den konfigurierten Sandbox-Runner in einer isolierten Laufzeitumgebung ausgeführt wird. Derselbe Plugin-Code kann auch prozessintern ausgeführt werden, wenn der Site-Betreiber es von sandboxed: [] nach plugins: [] verschiebt — zum Beispiel auf Plattformen ohne verfügbaren Sandbox-Runner.

Wenn Sie noch nicht entschieden haben, ob Sie ein Sandbox- oder natives Plugin möchten, lesen Sie zuerst Ein Plugin-Format wählen.

Zwei Dateien

Jedes Sandbox-Plugin besteht aus zwei Teilen:

  1. Ein Deskriptor — ein kleines Objekt, das das Plugin beschreibt (id, Version, Fähigkeiten, Speicher, wo der Laufzeit-Einstiegspunkt zu finden ist). Wird zur Build-Zeit von astro.config.mjs importiert.
  2. Ein Sandbox-Einstiegspunkt — der Laufzeit-Code: Hooks, Routen, Speicherzugriff. Wird zur Anfragezeit in die Sandbox-Laufzeit geladen.

Die beiden Dateien befinden sich im selben Paket und laufen in völlig unterschiedlichen Umgebungen. Der Deskriptor sieht niemals den Laufzeit-Kontext; der Einstiegspunkt sieht niemals astro.config.mjs.

my-plugin/
├── src/
│   ├── index.ts          # Deskriptor — läuft zur Build-Zeit in Vite
│   └── sandbox-entry.ts  # Hooks, Routen, Speicher — läuft in der Sandbox-Laufzeit
├── package.json
└── tsconfig.json

Das Paket einrichten

  1. Erstellen Sie ein neues Verzeichnis und initialisieren Sie es als TypeScript-ES-Modul-Paket.

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

    Der "./sandbox"-Export ist das, worauf der entrypoint des Deskriptors verweist. Der Bundler baut beide Dateien nach dist/.

  2. Fügen Sie eine tsconfig.json hinzu:

    {
    	"compilerOptions": {
    		"target": "ES2022",
    		"module": "preserve",
    		"moduleResolution": "bundler",
    		"strict": true,
    		"esModuleInterop": true,
    		"declaration": true,
    		"outDir": "./dist",
    		"rootDir": "./src"
    	},
    	"include": ["src/**/*"],
    	"exclude": ["node_modules", "dist"]
    }

Den Deskriptor schreiben

Der Deskriptor ist eine Factory-Funktion, die einen PluginDescriptor zurückgibt. Er läuft zur Build-Zeit in Vite, was bedeutet, dass er nebenwirkungsfrei sein muss und keine Laufzeit-APIs verwenden kann (fetch, die Datenbank, Umgebungsvariablen — nichts davon existiert noch).

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

Einige wichtige Details:

  • format: "standard" ist erforderlich. Ohne es behandelt EmDash das Paket als natives Plugin und sucht nach einer anderen Form. Das format-Feld ist standardmäßig "native".
  • entrypoint ist ein Modul-Spezifizierer, kein Dateipfad. Verwenden Sie denselben String, den Sie an import übergeben würden — normalerweise "<package-name>/sandbox". Der Paketname kann gescoped sein (@my-org/plugin-hello); die Plugin-id kann es nicht.
  • id ist ein URL-sicherer Slug, nicht der npm-Paketname. Es muss /^[a-z][a-z0-9_-]*$/ entsprechen — mit einem Kleinbuchstaben beginnen, gefolgt von Buchstaben, Ziffern, Bindestrichen oder Unterstrichen. Die id wird sowohl als einzelnes Pfadsegment in Plugin-Routen-URLs (/_emdash/api/plugins/<id>/...) als auch als Teil von generierten SQL-Identifikatoren für Plugin-Speicherindizes verwendet, daher schlagen @, /, führende Ziffern und Großbuchstaben alle zur Laufzeit fehl. Paaren Sie eine ungescoped id wie plugin-hello mit einem gescoped npm-Paketnamen in entrypoint.
  • Fähigkeiten, allowedHosts und Speicher befinden sich im Deskriptor. Der Sandbox-Einstiegspunkt deklariert sie nicht — er kann nur verwenden, was der Deskriptor erlaubt.
  • Fügen Sie hier keine Laufzeit-Logik ein. Kein Top-Level-await, kein Modul-Level-fetch, kein Lesen von Dateien. Der Deskriptor sind Metadaten.

Den Sandbox-Einstiegspunkt schreiben

Die Laufzeit-Seite. Diese Datei wird zur Anfragezeit in die Sandbox-Laufzeit geladen, ohne Zugriff auf irgendetwas außer dem, was ctx bereitstellt.

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 };
			},
		},
	},
});

Wissenswerte Dinge:

  • definePlugin() im Sandbox-Einstiegspunkt nimmt nur { hooks, routes }. Keine id, keine version, keine capabilities — diese kommen vom Deskriptor. EmDash wirft zur Build-Zeit einen Fehler, wenn Sie versuchen, sie hier zu übergeben.
  • Hook-Handler nehmen (event, ctx). Die Event-Form hängt vom Hook-Namen ab; siehe die Hooks-Referenz.
  • Routen-Handler nehmen (routeCtx, ctx) — zwei Argumente. routeCtx hat { input, request, requestMeta }; ctx ist derselbe PluginContext, den Sie in Hooks erhalten. Routen sind unter /_emdash/api/plugins/<plugin-id>/<route-name> erreichbar.
  • ctx.storage.events funktioniert, weil events im Deskriptor deklariert wurde. Der Zugriff auf eine nicht deklarierte Sammlung wirft einen Fehler.
  • ctx.kv ist immer verfügbar — ein pro-Plugin-Key-Value-Speicher mit get, set, delete und list(prefix).

Das Plugin registrieren

Importieren Sie in der astro.config.mjs Ihrer Site die Deskriptor-Factory und übergeben Sie sie an die EmDash-Integration. Sandbox-Plugins gehen in sandboxed: []; prozessinterne Plugins gehen in plugins: []. Ein Plugin im Standardformat funktioniert in beiden — beginnen Sie mit 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 ist das austauschbare Teil. Das obige Beispiel verwendet sandbox() von @emdash-cms/cloudflare, dem Sandbox-Runner, den die meisten Sites heute verwenden. Runner für andere Plattformen sind in Entwicklung. Wenn kein Runner konfiguriert ist (oder der konfigurierte Runner als nicht verfügbar auf der aktuellen Plattform meldet), werden sandboxed: []-Plugins beim Start übersprungen — um dasselbe Plugin prozessintern auszuführen, verschieben Sie es von sandboxed: [] nach plugins: [].

Bauen und ausführen

Aus dem Plugin-Verzeichnis:

pnpm build

Verknüpfen oder installieren Sie im Site-Verzeichnis das Plugin (pnpm add @my-org/plugin-hello oder ein Workspace-Link) und starten Sie dann den Dev-Server. Sie sollten [hello] Content saved … in den Logs sehen, wenn Sie das nächste Mal einen Inhalt im Admin speichern, und GET /_emdash/api/plugins/plugin-hello/recent sollte die letzten zehn Speicher-Events zurückgeben.

Was als Nächstes zu lesen ist