你的第一个沙箱插件

本页内容

本指南将带你从头开始构建一个最小的沙箱插件——一个记录每次内容保存并暴露单个 API 路由的插件。完成后,你将拥有一个通过配置的沙箱运行器在隔离运行时中运行的插件。如果站点管理员选择将其从 sandboxed: [] 移到 plugins: [],同样的插件代码也可以在进程内运行——例如在没有沙箱运行器的平台上。

如果你还没有决定是要沙箱插件还是原生插件,请先阅读选择插件格式

两个文件

每个沙箱插件包含两个部分:

  1. 描述符 — 一个描述插件的小对象(id、版本、能力、存储、运行时入口的位置)。在构建时由 astro.config.mjs 导入。
  2. 沙箱入口 — 运行时代码:钩子、路由、存储访问。在请求时加载到沙箱运行时中。

这两个文件位于同一个包中,但在完全不同的环境中运行。描述符永远看不到运行时上下文;入口永远看不到 astro.config.mjs

my-plugin/
├── src/
│   ├── index.ts          # 描述符 — 在构建时的 Vite 中运行
│   └── sandbox-entry.ts  # 钩子、路由、存储 — 在沙箱运行时中运行
├── package.json
└── tsconfig.json

设置包

  1. 创建一个新目录并将其初始化为 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"
    	}
    }

    "./sandbox" 导出是描述符的 entrypoint 将指向的内容。打包器将两个文件都构建到 dist/ 中。

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

编写描述符

描述符是一个返回 PluginDescriptor 的工厂函数。它在构建时的 Vite 中运行,这意味着它必须是无副作用的,不能使用任何运行时 API(fetch、数据库、环境变量——这些都还不存在)。

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

一些重要的细节:

  • format: "standard" 是必需的。 没有它,EmDash 会将包视为原生插件并寻找不同的结构。format 字段默认为 "native"
  • entrypoint 是一个模块说明符,而不是文件路径。使用你传递给 import 的相同字符串——通常是 "<package-name>/sandbox"。包名可以有作用域(@my-org/plugin-hello);插件 id 不能有。
  • id 是一个 URL 安全的 slug,而不是 npm 包名。 它必须匹配 /^[a-z][a-z0-9_-]*$/ — 以小写字母开头,然后是字母、数字、连字符或下划线。id 既用作插件路由 URL 中的单个路径段(/_emdash/api/plugins/<id>/...),也用作插件存储索引生成的 SQL 标识符的一部分,因此 @/、前导数字和大写字母都会在运行时失败。将像 plugin-hello 这样的无作用域 identrypoint 中的有作用域 npm 包名配对。
  • 能力、allowedHosts 和存储位于描述符上。 沙箱入口不声明它们——它只能使用描述符允许的内容。
  • 不要在这里放置运行时逻辑。 不要顶层 await,不要模块级 fetch,不要读取文件。描述符是元数据。

编写沙箱入口

运行时端。此文件在请求时加载到沙箱运行时中,除了 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 };
			},
		},
	},
});

值得了解的事项:

  • 沙箱入口中的 definePlugin() 只接受 { hooks, routes } 没有 id,没有 version,没有 capabilities——这些来自描述符。如果你尝试在这里传递它们,EmDash 会在构建时抛出错误。
  • 钩子处理器接受 (event, ctx) 事件形状取决于钩子名称;参见钩子参考
  • 路由处理器接受 (routeCtx, ctx) — 两个参数。routeCtx{ input, request, requestMeta }ctx 是你在钩子中获得的相同 PluginContext。路由可以在 /_emdash/api/plugins/<plugin-id>/<route-name> 访问。
  • ctx.storage.events 有效是因为 events 在描述符上声明了。 访问未声明的集合会抛出错误。
  • ctx.kv 始终可用 — 一个具有 getsetdeletelist(prefix) 的每个插件的键值存储。

注册插件

在你站点的 astro.config.mjs 中,导入描述符工厂并将其传递给 EmDash 集成。沙箱插件放在 sandboxed: [] 中;进程内插件放在 plugins: [] 中。标准格式插件在两者中都有效——从 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 是可插拔的部分。上面的示例使用来自 @emdash-cms/cloudflaresandbox(),这是当今大多数站点使用的沙箱运行器。其他平台的运行器正在开发中。如果没有配置运行器(或配置的运行器在当前平台上报告不可用),sandboxed: [] 插件会在启动时被跳过——要在进程内运行相同的插件,请将其从 sandboxed: [] 移到 plugins: []

构建和运行

在插件目录中:

pnpm build

在站点目录中,链接或安装插件(pnpm add @my-org/plugin-hello 或工作区链接),然后启动开发服务器。下次你在管理面板中保存一条内容时,你应该在日志中看到 [hello] Content saved …,并且 GET /_emdash/api/plugins/plugin-hello/recent 应该返回最后十个保存事件。

接下来阅读什么