feat(server): add HTTP file-based routing support

- Add file-based HTTP routing with httpDir and httpPrefix config options
- Create defineHttp<TBody>() helper for type-safe route definitions
- Support dynamic routes with [param].ts file naming convention
- Add CORS support for cross-origin requests
- Allow merging file routes with inline http config
- RPC server now supports attaching to existing HTTP server via server option
- Add comprehensive documentation for HTTP routing
This commit is contained in:
yhh
2025-12-31 09:52:45 +08:00
parent 12051d987f
commit d3e489aad3
19 changed files with 1226 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
/**
* @zh HTTP 模块导出
* @en HTTP module exports
*/
export * from './types.js';
export { createHttpRouter } from './router.js';

View File

@@ -0,0 +1,263 @@
/**
* @zh HTTP 路由器
* @en HTTP Router
*
* @zh 简洁的 HTTP 路由实现,支持与 WebSocket 共用端口
* @en Simple HTTP router implementation, supports sharing port with WebSocket
*/
import type { IncomingMessage, ServerResponse } from 'node:http';
import type {
HttpRequest,
HttpResponse,
HttpHandler,
HttpRoutes,
CorsOptions,
} from './types.js';
/**
* @zh 创建 HTTP 请求对象
* @en Create HTTP request object
*/
async function createRequest(req: IncomingMessage): Promise<HttpRequest> {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
// 解析查询参数
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// 解析请求体
let body: unknown = null;
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
body = await parseBody(req);
}
// 获取客户端 IP
const ip =
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
req.socket?.remoteAddress ||
'unknown';
return {
raw: req,
method: req.method ?? 'GET',
path: url.pathname,
query,
headers: req.headers as Record<string, string | string[] | undefined>,
body,
ip,
};
}
/**
* @zh 解析请求体
* @en Parse request body
*/
function parseBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf-8');
if (!rawBody) {
resolve(null);
return;
}
const contentType = req.headers['content-type'] ?? '';
if (contentType.includes('application/json')) {
try {
resolve(JSON.parse(rawBody));
} catch {
resolve(rawBody);
}
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(rawBody);
const result: Record<string, string> = {};
params.forEach((value, key) => {
result[key] = value;
});
resolve(result);
} else {
resolve(rawBody);
}
});
req.on('error', () => {
resolve(null);
});
});
}
/**
* @zh 创建 HTTP 响应对象
* @en Create HTTP response object
*/
function createResponse(res: ServerResponse): HttpResponse {
let statusCode = 200;
const response: HttpResponse = {
raw: res,
status(code: number) {
statusCode = code;
return response;
},
header(name: string, value: string) {
res.setHeader(name, value);
return response;
},
json(data: unknown) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.statusCode = statusCode;
res.end(JSON.stringify(data));
},
text(data: string) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.statusCode = statusCode;
res.end(data);
},
error(code: number, message: string) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.statusCode = code;
res.end(JSON.stringify({ error: message }));
},
};
return response;
}
/**
* @zh 应用 CORS 头
* @en Apply CORS headers
*/
function applyCors(res: ServerResponse, req: IncomingMessage, cors: CorsOptions): void {
const origin = req.headers.origin;
// 处理 origin
if (cors.origin === true || cors.origin === '*') {
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
} else if (typeof cors.origin === 'string') {
res.setHeader('Access-Control-Allow-Origin', cors.origin);
} else if (Array.isArray(cors.origin) && origin && cors.origin.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
// 允许的方法
if (cors.methods) {
res.setHeader('Access-Control-Allow-Methods', cors.methods.join(', '));
} else {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
}
// 允许的头
if (cors.allowedHeaders) {
res.setHeader('Access-Control-Allow-Headers', cors.allowedHeaders.join(', '));
} else {
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
// 凭证
if (cors.credentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// 缓存
if (cors.maxAge) {
res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
}
}
/**
* @zh 创建 HTTP 路由器
* @en Create HTTP router
*/
export function createHttpRouter(routes: HttpRoutes, cors?: CorsOptions | boolean) {
// 解析路由
const parsedRoutes: Array<{
method: string;
path: string;
handler: HttpHandler;
}> = [];
for (const [path, handlerOrMethods] of Object.entries(routes)) {
if (typeof handlerOrMethods === 'function') {
// 简单形式:路径 -> 处理器(接受所有方法)
parsedRoutes.push({ method: '*', path, handler: handlerOrMethods });
} else {
// 对象形式:路径 -> { GET, POST, ... }
for (const [method, handler] of Object.entries(handlerOrMethods)) {
if (handler !== undefined) {
parsedRoutes.push({ method, path, handler });
}
}
}
}
// 默认 CORS 配置
const corsOptions: CorsOptions | null =
cors === true
? { origin: true, credentials: true }
: cors === false
? null
: cors ?? null;
/**
* @zh 处理 HTTP 请求
* @en Handle HTTP request
*/
return async function handleRequest(
req: IncomingMessage,
res: ServerResponse
): Promise<boolean> {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
const method = req.method ?? 'GET';
// 应用 CORS
if (corsOptions) {
applyCors(res, req, corsOptions);
// 处理预检请求
if (method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return true;
}
}
// 查找匹配的路由
const route = parsedRoutes.find(
(r) => r.path === path && (r.method === '*' || r.method === method)
);
if (!route) {
return false; // 未找到路由,让其他处理器处理
}
try {
const httpReq = await createRequest(req);
const httpRes = createResponse(res);
await route.handler(httpReq, httpRes);
return true;
} catch (error) {
console.error('[HTTP] Route handler error:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Internal Server Error' }));
return true;
}
};
}

View File

@@ -0,0 +1,161 @@
/**
* @zh HTTP 路由类型定义
* @en HTTP router type definitions
*/
import type { IncomingMessage, ServerResponse } from 'node:http';
/**
* @zh HTTP 请求上下文
* @en HTTP request context
*/
export interface HttpRequest {
/**
* @zh 原始请求对象
* @en Raw request object
*/
raw: IncomingMessage;
/**
* @zh 请求方法
* @en Request method
*/
method: string;
/**
* @zh 请求路径
* @en Request path
*/
path: string;
/**
* @zh 查询参数
* @en Query parameters
*/
query: Record<string, string>;
/**
* @zh 请求头
* @en Request headers
*/
headers: Record<string, string | string[] | undefined>;
/**
* @zh 解析后的 JSON 请求体
* @en Parsed JSON body
*/
body: unknown;
/**
* @zh 客户端 IP
* @en Client IP
*/
ip: string;
}
/**
* @zh HTTP 响应工具
* @en HTTP response utilities
*/
export interface HttpResponse {
/**
* @zh 原始响应对象
* @en Raw response object
*/
raw: ServerResponse;
/**
* @zh 设置状态码
* @en Set status code
*/
status(code: number): HttpResponse;
/**
* @zh 设置响应头
* @en Set response header
*/
header(name: string, value: string): HttpResponse;
/**
* @zh 发送 JSON 响应
* @en Send JSON response
*/
json(data: unknown): void;
/**
* @zh 发送文本响应
* @en Send text response
*/
text(data: string): void;
/**
* @zh 发送错误响应
* @en Send error response
*/
error(code: number, message: string): void;
}
/**
* @zh HTTP 路由处理器
* @en HTTP route handler
*/
export type HttpHandler = (req: HttpRequest, res: HttpResponse) => void | Promise<void>;
/**
* @zh HTTP 路由定义
* @en HTTP route definition
*/
export interface HttpRoute {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | '*';
path: string;
handler: HttpHandler;
}
/**
* @zh HTTP 路由配置
* @en HTTP routes configuration
*/
export type HttpRoutes = Record<string, HttpHandler | {
GET?: HttpHandler;
POST?: HttpHandler;
PUT?: HttpHandler;
DELETE?: HttpHandler;
PATCH?: HttpHandler;
OPTIONS?: HttpHandler;
}>;
/**
* @zh CORS 配置
* @en CORS configuration
*/
export interface CorsOptions {
/**
* @zh 允许的来源
* @en Allowed origins
*/
origin?: string | string[] | boolean;
/**
* @zh 允许的方法
* @en Allowed methods
*/
methods?: string[];
/**
* @zh 允许的请求头
* @en Allowed headers
*/
allowedHeaders?: string[];
/**
* @zh 是否允许携带凭证
* @en Allow credentials
*/
credentials?: boolean;
/**
* @zh 预检请求缓存时间(秒)
* @en Preflight cache max age
*/
maxAge?: number;
}