Repository navigation
Plugin Routes
插件通过 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>/ 为前缀。
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),用于登出时撤销会话
}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;
}type RouteHandler = (request: HttpRequest, reply: HttpReply) => unknown | Promise<unknown>;handler 的返回值会被自动 reply.send()(如果 reply 尚未发送)。你也可以手动调用 reply.code(201).send(result) 并返回 undefined。
| 值 | 行为 |
|---|---|
'public'(默认) |
无需认证 |
'user' |
需要认证(任何已登录用户) |
'admin' |
需要 ADMIN 角色 |
声明 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访问。
当插件需要做代理、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>;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只写实际响应头,不能自定义预检。
插件路由会自动出现在 /docs(Swagger UI)的 plugins 标签下。每个路由标注了认证要求:
Plugin route (auth: user)
需要认证的路由会附带 bearerAuth security scheme。kind: 'raw' 的路由不会进入 OpenAPI(响应形态由插件自定,无法用 JSON schema 描述)。
核心开发
插件开发
主题开发
前端包开发
RESTful API