跳转到内容

路由系统

createRouter 提供了完整的 HTTP 路由功能,支持路径参数、通配符、资源路由和路由分组。

import { createRouter } from "@ventostack/core";
const router = createRouter();
// HTTP 方法
router.get("/users", async (ctx) => ctx.json(await getUsers()));
router.post("/users", async (ctx) => ctx.json(await createUser(ctx.body)));
router.put("/users/:id", async (ctx) => ctx.json(await updateUser(ctx.params.id, ctx.body)));
router.patch("/users/:id", async (ctx) => ctx.json(await patchUser(ctx.params.id, ctx.body)));
router.delete("/users/:id", async (ctx) => ctx.json(await deleteUser(ctx.params.id)));

使用 :name 定义路径参数,通过 ctx.params 访问:

router.get("/users/:id<int>", async (ctx) => {
// ctx.params.id 被推导为 number
const { id } = ctx.params;
const user = await db.query(UserModel).where("id", "=", id).get();
return ctx.json(user);
});
// 多个参数
router.get("/orgs/:orgId/repos/:repoId", async (ctx) => {
const { orgId, repoId } = ctx.params;
return ctx.json({ orgId, repoId });
});

使用 :name<type> 语法为路径参数声明类型,框架会自动推导 TypeScript 类型并在运行时做转换和校验:

router.get("/users/:id<int>", async (ctx) => {
// ctx.params.id 被推导为 number,运行时自动 parseInt
const user = await db.query(UserModel).where("id", "=", ctx.params.id).get();
return ctx.json(user);
});
router.get("/events/:at<date>", async (ctx) => {
// ctx.params.at 被推导为 Date
return ctx.json({ at: ctx.params.at.toISOString() });
});
// 多个类型化参数
router.get("/users/:userId<int>/posts/:postId<int>", async (ctx) => {
// ctx.params.userId → number
// ctx.params.postId → number
return ctx.json({ userId: ctx.params.userId, postId: ctx.params.postId });
});
标记TypeScript 类型运行时转换默认正则
:name<string>string原样[^/]+
:name<int>numberparseInt\d+
:name<float>numberparseFloat-?\d+(\.\d+)?
:name<bool>booleanv === "true"true|false|1|0
:name<uuid>string原样UUID v4
:name<date>Datenew Date(v)ISO 8601

无类型标记的参数(如 :id)默认推导为 string

在类型标记后附加 (regex) 可覆盖默认正则:

// 年份必须是 4 位数字
router.get("/archive/:year<int>(\\d{4})", async (ctx) => {
return ctx.json({ year: ctx.params.year });
});
// 代码必须是大写两位字母
router.get("/items/:code<string>(^[A-Z]{2}$)", async (ctx) => {
return ctx.json({ code: ctx.params.code });
});

自定义正则只影响校验,不改变 TypeScript 推导类型。当参数值不匹配正则时,请求会自动返回 400 VALIDATION_ERROR

通过 Schema 声明查询参数,可同时获得 TypeScript 类型推导和运行时自动校验:

router.get("/users", {
query: {
page: { type: "int", default: 1, min: 1, description: "页码" },
limit: { type: "int", default: 20, min: 1, max: 100 },
search: { type: "string", max: 100 },
},
}, async (ctx) => {
// ctx.query.page → number (默认 1)
// ctx.query.limit → number (默认 20)
// ctx.query.search → string | undefined
const users = await getUsers(ctx.query.page, ctx.query.limit, ctx.query.search);
return ctx.json({ users });
});

不传 Schema 时保持现有兼容行为,ctx.queryRecord<string, string>

router.get("/legacy", async (ctx) => {
const { page = "1", limit = "20" } = ctx.query;
return ctx.json({ page, limit });
});

通过 body Schema 声明 JSON 请求体,框架自动解析、转换和校验:

router.post("/users", {
body: {
email: { type: "string", required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
age: { type: "int", min: 0, max: 150 },
},
}, async (ctx) => {
// ctx.body.email → string
// ctx.body.age → number | undefined
const user = await createUser(ctx.body.email, ctx.body.age);
return ctx.json(user, 201);
});

校验失败时自动返回 400 VALIDATION_ERROR,无需在 handler 中手动处理。

通过 headers Schema 声明需要提取和校验的请求头:

router.get("/users", {
headers: {
authorization: { type: "string", required: true },
"x-request-id": { type: "string" },
},
}, async (ctx) => {
// 声明的字段会按大小写不敏感匹配提取
const token = ctx.headers.get("authorization");
return ctx.json({ ok: true });
});

通过 formData Schema 声明 multipart 表单字段,支持文本字段类型转换和文件上传限制:

router.post("/upload", {
formData: {
title: { type: "string", required: true },
avatar: {
type: "file",
required: true,
maxSize: 5 * 1024 * 1024,
allowedMimeTypes: ["image/png", "image/jpeg"],
},
},
}, async (ctx) => {
// ctx.formData.title → string
// ctx.formData.avatar → File
return ctx.json({ title: ctx.formData.title, size: ctx.formData.avatar.size });
});

ctx.json(data) 返回 TypedResponse<T>,可从 handler 的返回类型中推导响应结构:

router.get("/users/:id", {
responses: {
200: {
id: { type: "int" },
name: { type: "string" },
},
},
}, async (ctx) => {
const user = await getUser(ctx.params.id);
return ctx.json(user); // TypedResponse<{ id: number; name: string }>
});

responses Schema 同时用于 OpenAPI 文档自动生成。

路由中的 Schema 声明会自动转换为 OpenAPI Operation,无需手写两遍:

router.get("/users", {
query: {
page: { type: "int", default: 1, description: "页码" },
},
headers: {
authorization: { type: "string", required: true },
},
responses: {
200: {
users: { type: "array", items: { type: "object", properties: { id: { type: "int" }, name: { type: "string" } } } },
},
},
}, async (ctx) => {
return ctx.json({ users: [] });
});

生成的 OpenAPI 将自动包含:

  • Query 参数(带类型、默认值、描述)
  • Header 参数(带 required 标记)
  • Request Body(JSON 或 multipart)
  • Response Schema(按状态码组织)
// 匹配 /static/xxx 下的所有路径
router.get("/static/*", async (ctx) => {
const filePath = ctx.params["*"];
const file = Bun.file(`./public/${filePath}`);
return new Response(file);
});

使用 router.resource() 快速定义 RESTful 资源路由:

router.resource("/users", {
index: async (ctx) => ctx.json(await getUsers()), // GET /users
show: async (ctx) => ctx.json(await getUser(ctx.params.id)), // GET /users/:id
create: async (ctx) => ctx.json(await createUser(ctx.body), 201), // POST /users
update: async (ctx) => ctx.json(await updateUser(ctx.params.id, ctx.body)), // PUT /users/:id
destroy: async (ctx) => ctx.json(await deleteUser(ctx.params.id)), // DELETE /users/:id
});

等价于手动定义:

  • GET /usersindex
  • GET /users/:idshow
  • POST /userscreate
  • PUT /users/:idupdate
  • DELETE /users/:iddestroy

使用 router.group() 创建带前缀的路由分组,分组内的路由会自动拼接前缀,并可统一附加中间件:

const router = createRouter();
// 基础分组
router.group("/api/v1", (api) => {
api.get("/users", async (ctx) => ctx.json(await getUsers()));
api.post("/users", async (ctx) => ctx.json(await createUser(ctx.body), 201));
api.get("/users/:id", async (ctx) => ctx.json(await getUser(ctx.params.id)));
});
// 嵌套分组
router.group("/api", (api) => {
api.group("/v2", (v2) => {
v2.get("/users", async (ctx) => ctx.json(await getUsersV2()));
});
});

分组支持统一附加中间件:

const requireAuth: Middleware = async (ctx, next) => {
const token = ctx.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return ctx.json({ error: "Unauthorized" }, 401);
return next();
};
// /admin 下的所有路由都需要认证
router.group("/admin", (admin) => {
admin.get("/dashboard", async (ctx) => ctx.json({ stats: {} }));
admin.get("/settings", async (ctx) => ctx.json({ config: {} }));
}, requireAuth);

分组中间件与路由级中间件会按顺序组合:先执行分组中间件,再执行路由中间件。

如果需要将独立创建的 Router 合并到当前 Router,使用 merge

const subRouter = createRouter();
subRouter.get("/health", async (ctx) => ctx.json({ status: "ok" }));
const mainRouter = createRouter();
mainRouter.merge(subRouter);

路由级中间件只对该路由生效:

const requireAuth: Middleware = async (ctx, next) => {
const token = ctx.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return ctx.json({ error: "Unauthorized" }, 401);
ctx.state.user = await jwt.verify(token);
await next();
};
// 所有路由都需要认证
router.use(requireAuth);
router.get("/protected", async (ctx) => ctx.json(ctx.state.user));
const app = createApp({ port: 3000 });
app.use(router);
await app.listen();
interface Router {
get<Path extends string>(path: Path, handler: RouteHandler<InferParams<Path>>, ...middleware: Middleware[]): Router;
get<Path extends string>(path: Path, config: RouteConfig<InferParams<Path>>, handler: RouteHandler<...>, ...middleware: Middleware[]): Router;
post<Path extends string>(path: Path, handler: RouteHandler<InferParams<Path>>, ...middleware: Middleware[]): Router;
post<Path extends string>(path: Path, config: RouteConfig<InferParams<Path>>, handler: RouteHandler<...>, ...middleware: Middleware[]): Router;
put<Path extends string>(path: Path, handler: RouteHandler<InferParams<Path>>, ...middleware: Middleware[]): Router;
put<Path extends string>(path: Path, config: RouteConfig<InferParams<Path>>, handler: RouteHandler<...>, ...middleware: Middleware[]): Router;
patch<Path extends string>(path: Path, handler: RouteHandler<InferParams<Path>>, ...middleware: Middleware[]): Router;
patch<Path extends string>(path: Path, config: RouteConfig<InferParams<Path>>, handler: RouteHandler<...>, ...middleware: Middleware[]): Router;
delete<Path extends string>(path: Path, handler: RouteHandler<InferParams<Path>>, ...middleware: Middleware[]): Router;
delete<Path extends string>(path: Path, config: RouteConfig<InferParams<Path>>, handler: RouteHandler<...>, ...middleware: Middleware[]): Router;
use(...middleware: Middleware[]): Router;
group(prefix: string, callback: (group: Router) => void, ...middleware: Middleware[]): Router;
resource(prefix: string, handlers: ResourceHandlers, ...middleware: Middleware[]): Router;
merge(router: Router): Router;
}
type RouteHandler<
TParams extends Record<string, unknown> = Record<string, string>,
TQuery extends Record<string, unknown> = Record<string, string>,
TBody extends Record<string, unknown> = Record<string, unknown>,
TFormData extends Record<string, unknown> = Record<string, unknown>,
TResponse = unknown,
> = (ctx: Context<TParams, TQuery, TBody, TFormData>) => Promise<TypedResponse<TResponse> | Response> | TypedResponse<TResponse> | Response;