你的第一个原生插件

本页内容

原生插件是一个 npm 包,EmDash 会将它导入到与 Astro 站点相同的进程中。本教程会创建一个记录内容保存的插件,将其安装到站点中,并在 astro.config.mjs 中注册。

当插件需要进程内功能时,例如 React 管理组件、Astro 渲染组件或受信任的页面片段,请使用原生格式。如果钩子、路由、存储和 Block Kit 能满足需求,请从沙箱插件开始。选择插件格式对这两种格式做了比较。

前置条件

从一个使用 pnpm 且能运行开发服务器的 EmDash 站点开始。

这些命令将站点目录称为 my-emdash-site,并在它旁边创建 plugin-activity。请将 my-emdash-site 替换为你的站点目录名称。

创建并注册包

  1. 在站点旁边创建一个原生包目录。

    mkdir -p ../plugin-activity/src
    cd ../plugin-activity
  2. 添加包元数据和构建脚本。

    {
        "name": "@example/plugin-activity",
        "version": "0.1.0",
        "type": "module",
        "main": "./dist/index.mjs",
        "exports": {
            ".": {
                "types": "./dist/index.d.mts",
                "import": "./dist/index.mjs"
            }
        },
        "files": ["dist"],
        "scripts": {
            "build": "tsdown src/index.ts --format esm --dts --clean",
            "dev": "tsdown src/index.ts --format esm --dts --watch",
            "typecheck": "tsc --noEmit"
        },
        "peerDependencies": {
            "emdash": "*"
        },
        "devDependencies": {
            "emdash": "*",
            "tsdown": "^0.20.0",
            "typescript": "^5.9.0"
        }
    }

    npm 包名称是 @example/plugin-activity。下面的运行时代码使用不带作用域的插件 ID plugin-activity,以便它能放入插件 API 路由的 URL 中。

  3. 添加 TypeScript 配置。

    {
        "compilerOptions": {
            "target": "ES2022",
            "module": "preserve",
            "moduleResolution": "bundler",
            "strict": true,
            "esModuleInterop": true,
            "declaration": true,
            "outDir": "./dist",
            "rootDir": "./src"
        },
        "include": ["src/**/*"],
        "exclude": ["node_modules", "dist"]
    }
  4. 安装包的依赖。

    pnpm install
  5. 创建带有内容保存钩子的 src/index.ts。

    import { definePlugin } from "emdash";
    import type { PluginDescriptor } from "emdash";
    
    export interface ActivityPluginOptions {
        logUpdates?: boolean;
    }
    
    export function activityPlugin(
        options: ActivityPluginOptions = {},
    ): PluginDescriptor<ActivityPluginOptions> {
        return {
            id: "plugin-activity",
            version: "0.1.0",
            format: "native",
            entrypoint: "@example/plugin-activity",
            options,
        };
    }
    
    export function createPlugin(options: ActivityPluginOptions = {}) {
        return definePlugin({
            id: "plugin-activity",
            version: "0.1.0",
            capabilities: ["content:read"],
            hooks: {
                "content:afterSave": async (event, ctx) => {
                    if (!event.isNew && options.logUpdates === false) return;
    
                    ctx.log.info("Content saved", {
                        collection: event.collection,
                        contentId: event.content.id,
                        isNew: event.isNew,
                    });
                },
            },
        });
    }
    
    export default createPlugin;

    content:afterSave 需要 content:read 能力。缺少该能力时,EmDash 会跳过该钩子。

  6. 构建该包。

    pnpm build
  7. 在站点中安装本地包。

    cd ../my-emdash-site
    pnpm add ../plugin-activity
  8. 在 EmDash 集成中注册描述符工厂。

    import { defineConfig } from "astro/config";
    import emdash from "emdash/astro";
    import { activityPlugin } from "@example/plugin-activity";
    
    export default defineConfig({
        integrations: [
            emdash({
                plugins: [activityPlugin({ logUpdates: true })],
            }),
        ],
    });

    原生描述符应放在 plugins 中,而不是 sandboxed 中。如果 sandboxed 数组中出现原生描述符,EmDash 会拒绝它。

  9. 启动站点,并在管理面板中保存一个条目。

    pnpm dev

    服务器日志会包含 Content saved,以及集合、内容 ID 和该条目是否为新建。

描述符与运行时的边界

包的导出有两项职责。EmDash 在不同阶段使用它们:

  • 描述符工厂 activityPlugin() 在 Astro 评估其配置时运行。它返回可序列化的构建时元数据:id、version、format、entrypoint 和 options。React 和 Astro 入口点也属于这个描述符。
  • 具名导出 createPlugin() 在 EmDash 初始化时运行。EmDash 从 entrypoint 导入它,传入序列化后的 options,并期望 definePlugin() 返回一个已解析的插件。

具名导出 createPlugin 是必需的。默认导出可能对包的使用者有用,但 EmDash 的原生加载器是按名称导入 createPlugin 的。

请保持描述符和 definePlugin() 中的 id 与 version 一致。使用不带作用域的短横线命名插件 ID,例如 plugin-activity;将 npm 作用域保留在包名称和 entrypoint 中。这样可以让该 ID 作为 API 路由 URL 中唯一的插件段使用。

插件标识与版本控制列出了可接受的 ID 和版本格式。

运行时行为属于 definePlugin():

  • capabilities 和 allowedHosts
  • storage
  • hooks 和 routes
  • admin 设置、页面、小组件和 Portable Text 声明

描述符承载 Astro 必须在构建时导入或暴露的静态条目。各专题指南会说明哪些管理字段需要在描述符和运行时中声明相匹配的内容。

原生路由处理程序

原生路由处理程序接收一个 RouteContext。它将已验证的输入和请求数据与常规的 PluginContext 结合在一起:

routes: {
	status: {
		permission: "plugins:read",
		handler: async (ctx) => ({
			pluginId: ctx.plugin.id,
			callerId: ctx.user?.id ?? null,
		}),
	},
},

对应的沙箱处理程序以两个参数接收 (routeCtx, ctx)。身份验证、权限、输入 schema 和路由 URL 在其他方面遵循共享的 API 路由约定。

当原生路由声明了 request.body 时,请用 definePluginRoute() 包装它;该辅助函数会根据请求体模式推断 ctx.input。带有 response: "raw" 的原生路由返回 pluginResponse()。 这两个辅助函数都从 emdash 导入。共享的 API 路由指南列出了请求体模式、限制、响应 策略和兼容性默认值。

读取密钥和绑定

原生插件中的钩子和路由处理程序接收的是插件上下文,而不是 Astro 上下文,因此其中无法使用 Astro.locals。请从运行时环境读取密钥和平台绑定。

从 process.env 读取部署密钥。在 Node.js 上,由托管平台的密钥管理器提供。在 Cloudflare Workers 上,使用 wrangler secret put 设置的密钥会通过兼容日期为 2025-04-01 或更晚的 nodejs_compat 标志进入 process.env,详见 Worker 密钥。

对于由站点管理员输入的凭据,请改为在生成的设置表单中声明 secret 字段。EmDash 在存储前会用 EMDASH_ENCRYPTION_KEY 加密该值,因此请在管理员保存该字段之前设置这个密钥。插件通过 ctx.settings.get() 读取该值。

在 Cloudflare Workers 上,队列、R2 存储桶或 send_email 等绑定是来自 cloudflare:workers 的 env 上的对象。Astro 在 Node.js 中加载 astro.config.mjs,而该文件会为描述符工厂导入插件包。Node.js 无法加载 cloudflare:workers,因此请在处理程序内部导入它,而不是在模块顶部。下面的钩子会将每个已保存条目的 ID 发送到绑定为 ACTIVITY_QUEUE 的队列:

hooks: {
	"content:afterSave": async (event) => {
		const { env } = await import("cloudflare:workers");
		await env.ACTIVITY_QUEUE.send({ contentId: event.content.id });
	},
},

cloudflare:workers 模块只能在 Workers 运行时中解析,因此导入它的插件只能在使用 Cloudflare 适配器的站点上工作。

要对该导入进行类型检查,请将 @cloudflare/workers-types 添加到包的 devDependencies 和 tsconfig.json 的 compilerOptions.types 中。然后在 Cloudflare.Env 接口上声明插件读取的每个绑定。下面的声明为上一个示例中的队列添加类型:

declare namespace Cloudflare {
	interface Env {
		ACTIVITY_QUEUE: Queue;
	}
}

添加其他界面