Skip to content

Plugin Routes

worable edited this page Oct 12, 2026 · 1 revision

路由定义

插件通过 routes 字段声明 HTTP 路由。内核的 PluginDispatcher 负责匹配和分发。

基本结构

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';

interface PluginRouteBase {
  method: HttpMethod;
  path: string;
  auth?: 'public' | 'user' | 'admin'; // 默认 'public'
  permission?: string; // 声明后自动要求认证
  timeout?: number | false; // 仅 raw 路由,毫秒;默认不限制
  bodyLimit?: number; // 仅 handler 路由,字节
  cors?: CorsPolicy | false; // 按路由 CORS
}

// 普通路由(默认):请求体已解析,返回值为 JSON
interface HandledPluginRoute extends PluginRouteBase {
  kind?: 'handler';
  handler: RouteHandler;
}

// 透传路由:拿到未解析的流与原始响应,自行接管(SSE 代理等)
interface RawPluginRoute extends PluginRouteBase {
  kind: 'raw';
  handler: RawRouteHandler;
}

type PluginRoute = HandledPluginRoute | RawPluginRoute;

大多数路由用默认的 kind: 'handler';仅当需要流式转发、透传任意状态码/大 body 时才用 kind: 'raw'(见 透传路由)。

路由路径

路径以 / 开头,支持 :param 路径参数:

routes: [
  { method: 'GET',  path: '/my-plugin/items',        handler: listItems },
  { method: 'GET',  path: '/my-plugin/items/:id',    handler: getItem },
  { method: 'POST', path: '/my-plugin/items',        handler: createItem },
  { method: 'PATCH',path: '/my-plugin/items/:id',    handler: updateItem },
  { method: 'DELETE',path: '/my-plugin/items/:id',   handler: deleteItem },
],

路由匹配优先级

当多个路由匹配同一请求时,静态段优先于参数段。例如 /my-plugin/settings 会优先于 /my-plugin/:id 匹配。相同优先级时按注册顺序。

路径末尾可用 * 通配剩余段(含多级、可为空),捕获到 params['*']:/relay/* 匹配 /relay/v1/chat,得到 params['*'] === 'v1/chat'。字面量段更多的路由仍优先于通配。

与内核路由的关系

内核路由是 Fastify 原生路由,优先级永远高于插件路由。插件路由由 catch-all /* 处理器分发。不要使用与内核路由冲突的路径(如 /auth/*、/admin/*、/themes/*、/plugins/*、/health、/ready、/nav/*、/platform/*)。

建议所有插件路由以 /<plugin-id>/ 为前缀。

请求与响应

HttpRequest

interface HttpRequest {
  params: Record<string, string>; // 路径参数(含 '*' 通配尾)
  query: Record<string, unknown>; // 查询字符串
  body: unknown; // 请求体(已解析)
  headers: Record<string, string | string[] | undefined>; // 原始请求头(Node 语义)
  ip?: string; // 客户端 IP
  user?: { id: string; role: string; email?: string }; // 认证用户(auth 路由才有)
  sessionToken?: string; // 会话 token(cookie 或 Bearer),用于登出时撤销会话
}

HttpReply

interface HttpReply {
  code(status: number): HttpReply; // 设置状态码
  send(payload?: unknown): void; // 发送响应
  setCookie?(name: string, value: string, options?: Record<string, unknown>): void; // 登录类插件用
  clearCookie?(name: string, options?: Record<string, unknown>): void;
}

RouteHandler

type RouteHandler = (request: HttpRequest, reply: HttpReply) => unknown | Promise<unknown>;

handler 的返回值会被自动 reply.send()(如果 reply 尚未发送)。你也可以手动调用 reply.code(201).send(result) 并返回 undefined。

认证与权限

auth 字段

值 行为
'public'(默认) 无需认证
'user' 需要认证(任何已登录用户)
'admin' 需要 ADMIN 角色

permission 字段

声明 permission 后,路由自动要求认证(即使 auth 未设置)。内核按以下顺序检查:

isActive? → requireAuth → requireRole(admin)? → hasPermission(role, permission)?

ADMIN 角色始终通过权限检查。USER 角色需要通过 roleTemplates 授权。详见 权限与 RBAC。

完整示例

import { defineModel, definePlugin } from '@stackpanel/sdk';
import type { PluginContext } from '@stackpanel/sdk';
import { z } from 'zod';

const itemSchema = z.object({
  name: z.string().min(1).max(191),
  price: z.number().int().positive(),
});

// 插件自有模型:内核在激活时建表,只暴露给本插件。
const itemModel = defineModel({
  kind: 'my-plugin/item',
  label: '条目',
  schema: itemSchema,
  indexes: [{ fields: ['name'], types: { name: 'string' } }],
});

let ctx: PluginContext;

export default definePlugin({
  manifest: {
    id: 'my-plugin',
    name: 'My Plugin',
    version: '1.0.0',
    permissions: ['my-plugin.read', 'my-plugin.write'],
    roleTemplates: [{ role: 'USER', permissions: ['my-plugin.read'] }],
  },

  customModels: [itemModel],

  onActivate(pluginCtx) {
    ctx = pluginCtx;
  },

  routes: [
    {
      method: 'GET',
      path: '/my-plugin/items',
      auth: 'user',
      permission: 'my-plugin.read',
      handler: async () => {
        const { items } = await ctx.extensions.list(itemModel, { page: 1, pageSize: 50 });
        return { items };
      },
    },
    {
      method: 'POST',
      path: '/my-plugin/items',
      auth: 'user',
      permission: 'my-plugin.write',
      handler: async (req, reply) => {
        const parsed = itemSchema.safeParse(req.body);
        if (!parsed.success) {
          return reply.code(400).send({ error: '请求参数无效' });
        }
        const item = await ctx.extensions.create(itemModel, parsed.data);
        return reply.code(201).send({ item });
      },
    },
  ],
});

插件不得使用裸 DB。PluginContext 不提供数据库句柄(ctx.db 已移除);所有自有数据经 ctx.extensions / ctx.tx 访问。

透传路由(raw)

当插件需要做代理、SSE 流式转发、或透传任意状态码/大 body 时,声明 kind: 'raw'。内核不解析请求体、不包装响应,把原始流与原始响应交给插件:

interface RawHttpRequest {
  method: string;
  url: string; // 原样 path + query
  params: Record<string, string>;
  query: URLSearchParams;
  headers: Record<string, string | string[] | undefined>;
  body: Readable; // 未解析的请求体流(无 body 时已结束)
  ip?: string;
  user?: { id: string; role: string; email?: string };
  raw: unknown; // 逃生舱:原生 IncomingMessage
}

interface RawHttpReply {
  status(code: number): RawHttpReply;
  header(name: string, value: string | string[]): RawHttpReply;
  write(chunk: string | Uint8Array): boolean;
  end(body?: string | Uint8Array): void;
  hijack(): void; // 接管 socket,内核不再发送响应
  onClientClose(fn: () => void): void; // 客户端断开回调
  raw: unknown; // 逃生舱:原生 ServerResponse
}

type RawRouteHandler = (request: RawHttpRequest, reply: RawHttpReply) => Promise<void>;

示例:逐步转发上游 SSE

routes: [
  {
    kind: 'raw',
    method: 'POST',
    path: '/relay/*',
    handler: async (req, reply) => {
      const upstream = await fetch(`https://api.example.com/${req.params['*']}`, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: (req.headers['authorization'] as string) ?? '',
        },
        // Node 的 fetch 用流做 body 时必须声明 duplex: 'half'
        body: req.body as unknown as BodyInit,
        duplex: 'half',
      } as RequestInit);

      reply.status(upstream.status);
      upstream.headers.forEach((value, name) => reply.header(name, value));

      const reader = upstream.body?.getReader();
      if (!reader) {
        reply.end();
        return;
      }
      // 客户端断开时取消上游读取,避免悬挂连接
      req.onClientClose(() => void reader.cancel().catch(() => {}));

      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        reply.write(value);
      }
      reply.end();
    },
  },
];

若上游是 OpenAI 兼容的计费网关,需要读 body 计算用量,就不能直接把流丢给 fetch:可在转发前 for await 读流做 tee/缓冲并按需转发,最后再结算。ctx.state 可用于并发计数与限速。

约定与边界:

  • raw 路由的 body 是未解析流,不受 bodyLimit 限制,也不做 JSON 解析(因此非法 JSON 不会被 400/413 拒绝)。
  • timeout 对 raw 路由生效(默认不限制);长连接 SSE 不要设置 timeout。
  • 必须留在 catch-all /* 派发机制内(插件是运行时动态激活的),因此仍受「内核路由优先」约束。
  • raw 路由同样经过 isActive/auth/permission/cors 守卫;返回 reply.hijack() 后由插件完全负责响应生命周期。
  • OPTIONS 预检由内核全局 @fastify/cors 处理,raw 路由的 cors 只写实际响应头,不能自定义预检。

OpenAPI 文档

插件路由会自动出现在 /docs(Swagger UI)的 plugins 标签下。每个路由标注了认证要求:

Plugin route (auth: user)

需要认证的路由会附带 bearerAuth security scheme。kind: 'raw' 的路由不会进入 OpenAPI(响应形态由插件自定,无法用 JSON schema 描述)。

Clone this wiki locally