本指南将带你从头开始构建一个最小的沙箱插件——一个记录每次内容保存并暴露单个 API 路由的插件。完成后,你将拥有一个通过配置的沙箱运行器在隔离运行时中运行的插件。如果站点管理员选择将其从 sandboxed: [] 移到 plugins: [],同样的插件代码也可以在进程内运行——例如在没有沙箱运行器的平台上。
如果你还没有决定是要沙箱插件还是原生插件,请先阅读选择插件格式。
两个文件
每个沙箱插件包含两个部分:
- 描述符 — 一个描述插件的小对象(id、版本、能力、存储、运行时入口的位置)。在构建时由
astro.config.mjs导入。 - 沙箱入口 — 运行时代码:钩子、路由、存储访问。在请求时加载到沙箱运行时中。
这两个文件位于同一个包中,但在完全不同的环境中运行。描述符永远看不到运行时上下文;入口永远看不到 astro.config.mjs。
my-plugin/
├── src/
│ ├── index.ts # 描述符 — 在构建时的 Vite 中运行
│ └── sandbox-entry.ts # 钩子、路由、存储 — 在沙箱运行时中运行
├── package.json
└── tsconfig.json
设置包
-
创建一个新目录并将其初始化为 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/中。 -
添加
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这样的无作用域id与entrypoint中的有作用域 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始终可用 — 一个具有get、set、delete和list(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/cloudflare 的 sandbox(),这是当今大多数站点使用的沙箱运行器。其他平台的运行器正在开发中。如果没有配置运行器(或配置的运行器在当前平台上报告不可用),sandboxed: [] 插件会在启动时被跳过——要在进程内运行相同的插件,请将其从 sandboxed: [] 移到 plugins: []。
构建和运行
在插件目录中:
pnpm build
在站点目录中,链接或安装插件(pnpm add @my-org/plugin-hello 或工作区链接),然后启动开发服务器。下次你在管理面板中保存一条内容时,你应该在日志中看到 [hello] Content saved …,并且 GET /_emdash/api/plugins/plugin-hello/recent 应该返回最后十个保存事件。