你的第一个沙盒插件

本页内容

本指南从零开始构建一个最小的沙盒插件。该插件记录每次内容保存并暴露一个 API 路由。它运行在由配置的沙盒运行器提供的隔离运行时中。当站点运维人员将其从 sandboxed: [] 移到 plugins: [] 时,相同的代码也可在进程内运行,例如在没有沙盒运行器的平台上。

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

两个部分

一个沙盒插件由以下组成:

  1. emdash-plugin.jsonc — 手动编辑的清单:身份、信任契约(能力、主机、存储)和配置文件字段。不含代码。
  2. src/plugin.ts — 运行时:钩子和路由。从 emdash/plugin 仅导入类型;无运行时 emdash 导入。

emdash-plugin build 读取两者并输出站点消费的 dist/ 制品。

以下示例展示了一个完整插件的文件布局:

my-plugin/
├── emdash-plugin.jsonc   # 身份 + 信任契约 + 配置文件
├── src/
│   └── plugin.ts         # 钩子、路由——在沙盒运行时中运行
├── package.json
└── tsconfig.json

设置包

  1. 创建目录和 package.json。构建命令是 emdash-plugin build;不需要编写 tsdown 调用。

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

    "." 是站点导入的生成的描述符;"./sandbox" 是构建后的运行时文件。emdash-plugin build 两者都会生成。

  2. 添加 tsconfig.json

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

编写清单

emdash-plugin.jsonc 携带插件的身份(slug)、信任契约(capabilitiesallowedHostsstorage)、配置文件字段和发布者固定。将 publisher 设置为您的 Atmosphere 账户的 DID。

以下示例展示了 hello 插件的完整清单:

{
	"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",

	"slug": "plugin-hello",
	"publisher": "did:plc:abc123def456", // 您的 Atmosphere 账户 DID

	"license": "MIT",
	"author": { "name": "Jane Doe", "url": "https://example.com" },
	"security": { "email": "[email protected]" },

	"capabilities": [],
	"allowedHosts": [],
	"storage": { "events": { "indexes": ["timestamp"] } }
}

关于此清单的说明:

  • slug 是 URL 安全 ID,不是 npm 包名。 /^[a-z][a-z0-9_-]*$/,最多 64 个字符。它是插件路由 URL 中的单一路径段(/_emdash/api/plugins/<slug>/...)和存储索引生成的 SQL 标识符的一部分,因此 @/、前导数字和大写字母都会失败。将无范围的 slugplugin-hello)与有范围的 npm 包名搭配使用。
  • storage 预先声明集合。 ctx.storage.events 在运行时能工作仅因为 events 在此处已声明。访问未声明的集合会抛出错误。
  • 省略了 version 构建从 package.json 读取它,因此只有一个真实来源。参见清单参考
  • 信任契约就是授权。 后续更改 capabilitiesallowedHostsstorage 需要递增版本号——已安装的站点同意的是旧契约。

编写运行时

src/plugin.ts 默认导出一个用 satisfies SandboxedPlugin 标注的裸对象。emdash/plugin 仅提供类型,因此沙盒插件不依赖 emdash 的运行时。

以下示例将每次内容保存记录到插件存储中,并暴露一个 recent 路由返回最近十次保存:

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;

关于运行时文件的说明:

  • satisfies SandboxedPlugin 为所有内容提供类型。 它从钩子名称推断 event(使用完整的规范事件类型)并将 ctx 推断为 PluginContext,因此处理程序不需要参数标注。拼错的钩子键如 "content:afterSav" 是编译错误。
  • 钩子处理程序接受 (event, ctx) 事件形式取决于钩子名称;参见钩子指南
  • 路由处理程序接受 (routeCtx, ctx) — 两个参数。routeCtx{ input, request, requestMeta? }ctx 是同一个 PluginContext。路由可通过 /_emdash/api/plugins/<slug>/<route-name> 访问。
  • ctx.storage.events 能工作是因为 events 在清单中已声明。
  • ctx.kv 始终可用 — 每个插件的键值存储,支持 getsetdeletelist(prefix)

注册插件

在站点的 astro.config.mjs 中,导入插件的默认导出并传入。沙盒插件放在 sandboxed: [] 中;进程内插件放在 plugins: [] 中。沙盒插件在两者中都能工作。以下示例使用 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 是可插拔的部分。示例使用来自 @emdash-cms/cloudflaresandbox(),这是目前大多数站点使用的运行器。如果未配置运行器(或配置的运行器在当前平台不可用),sandboxed: [] 中的插件会在启动时被跳过——将插件移到 plugins: [] 中以在进程内运行。

构建和运行

从插件目录:

emdash-plugin validate   # 先进行 schema 检查清单
emdash-plugin build      # 输出 dist/

在编辑循环中,运行 emdash-plugin dev(保存时重新构建,构建失败时保留上一个正常的 dist/)。在站点中,安装或链接插件(pnpm add file:../plugin-hello 或工作区链接)并启动开发服务器。在管理面板中保存一段内容,您应该在日志中看到 Content saved …GET /_emdash/api/plugins/plugin-hello/recent 返回最近十次保存事件。

接下来阅读