From d3e489aad302288683f64d1cf2386157b3ca5e4e Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Wed, 31 Dec 2025 09:52:45 +0800 Subject: [PATCH] feat(server): add HTTP file-based routing support - Add file-based HTTP routing with httpDir and httpPrefix config options - Create defineHttp() 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 --- .../content/docs/en/modules/network/server.md | 130 +++++++++ .../content/docs/modules/network/server.md | 130 +++++++++ packages/framework/network/CHANGELOG.md | 7 + packages/framework/network/package.json | 2 +- packages/framework/rpc/CHANGELOG.md | 44 +++ packages/framework/rpc/package.json | 2 +- packages/framework/rpc/src/server/index.ts | 39 ++- packages/framework/server/CHANGELOG.md | 49 ++++ packages/framework/server/package.json | 2 +- packages/framework/server/src/core/server.ts | 141 ++++++++-- .../framework/server/src/helpers/define.ts | 36 ++- packages/framework/server/src/http/index.ts | 7 + packages/framework/server/src/http/router.ts | 263 ++++++++++++++++++ packages/framework/server/src/http/types.ts | 161 +++++++++++ packages/framework/server/src/index.ts | 14 +- .../framework/server/src/router/loader.ts | 113 +++++++- packages/framework/server/src/types/index.ts | 114 ++++++++ packages/framework/transaction/CHANGELOG.md | 7 + packages/framework/transaction/package.json | 2 +- 19 files changed, 1226 insertions(+), 37 deletions(-) create mode 100644 packages/framework/server/src/http/index.ts create mode 100644 packages/framework/server/src/http/router.ts create mode 100644 packages/framework/server/src/http/types.ts diff --git a/docs/src/content/docs/en/modules/network/server.md b/docs/src/content/docs/en/modules/network/server.md index c883fb21..b2fdc0f5 100644 --- a/docs/src/content/docs/en/modules/network/server.md +++ b/docs/src/content/docs/en/modules/network/server.md @@ -79,10 +79,140 @@ await server.start() | `tickRate` | `number` | `20` | Global tick rate (Hz) | | `apiDir` | `string` | `'src/api'` | API handlers directory | | `msgDir` | `string` | `'src/msg'` | Message handlers directory | +| `httpDir` | `string` | `'src/http'` | HTTP routes directory | +| `httpPrefix` | `string` | `'/api'` | HTTP routes prefix | +| `cors` | `boolean \| CorsOptions` | - | CORS configuration | | `onStart` | `(port) => void` | - | Start callback | | `onConnect` | `(conn) => void` | - | Connection callback | | `onDisconnect` | `(conn) => void` | - | Disconnect callback | +## HTTP Routing + +Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios. + +### File-based Routing + +Create route files in the `httpDir` directory, automatically mapped to HTTP endpoints: + +``` +src/http/ +├── login.ts → POST /api/login +├── register.ts → POST /api/register +├── health.ts → GET /api/health (set method: 'GET') +└── users/ + └── [id].ts → POST /api/users/:id (dynamic route) +``` + +### Define Routes + +Use `defineHttp` to define type-safe route handlers: + +```typescript +// src/http/login.ts +import { defineHttp } from '@esengine/server' + +interface LoginBody { + username: string + password: string +} + +export default defineHttp({ + method: 'POST', // Default POST, options: GET/PUT/DELETE/PATCH + handler(req, res) { + const { username, password } = req.body + + // Validate credentials... + if (!isValid(username, password)) { + res.error(401, 'Invalid credentials') + return + } + + // Generate token... + res.json({ token: '...', userId: '...' }) + } +}) +``` + +### Request Object (HttpRequest) + +```typescript +interface HttpRequest { + raw: IncomingMessage // Node.js raw request + method: string // Request method + path: string // Request path + query: Record // Query parameters + headers: Record + body: unknown // Parsed JSON body + ip: string // Client IP +} +``` + +### Response Object (HttpResponse) + +```typescript +interface HttpResponse { + raw: ServerResponse // Node.js raw response + status(code: number): HttpResponse // Set status code (chainable) + header(name: string, value: string): HttpResponse // Set header (chainable) + json(data: unknown): void // Send JSON + text(data: string): void // Send text + error(code: number, message: string): void // Send error +} +``` + +### Usage Example + +```typescript +// Complete login server example +import { createServer, defineHttp } from '@esengine/server' +import { createJwtAuthProvider, withAuth } from '@esengine/server/auth' + +const jwtProvider = createJwtAuthProvider({ + secret: process.env.JWT_SECRET!, + expiresIn: 3600 * 24, +}) + +const server = await createServer({ + port: 8080, + httpDir: 'src/http', + httpPrefix: '/api', + cors: true, +}) + +// Wrap with auth (WebSocket connections validate token) +const authServer = withAuth(server, { + provider: jwtProvider, + extractCredentials: (req) => { + const url = new URL(req.url, 'http://localhost') + return url.searchParams.get('token') + }, +}) + +await authServer.start() +// HTTP: http://localhost:8080/api/* +// WebSocket: ws://localhost:8080?token=xxx +``` + +### Inline Routes + +Routes can also be defined directly in configuration (merged with file routes, inline takes priority): + +```typescript +const server = await createServer({ + port: 8080, + http: { + '/health': { + GET: (req, res) => res.json({ status: 'ok' }), + }, + '/webhook': async (req, res) => { + // Accepts all methods + await handleWebhook(req.body) + res.json({ received: true }) + }, + }, +}) +``` + ## Room System Room is the base class for game rooms, managing players and game state. diff --git a/docs/src/content/docs/modules/network/server.md b/docs/src/content/docs/modules/network/server.md index 4cb6f8ac..eeb9aec9 100644 --- a/docs/src/content/docs/modules/network/server.md +++ b/docs/src/content/docs/modules/network/server.md @@ -79,10 +79,140 @@ await server.start() | `tickRate` | `number` | `20` | 全局 Tick 频率 (Hz) | | `apiDir` | `string` | `'src/api'` | API 处理器目录 | | `msgDir` | `string` | `'src/msg'` | 消息处理器目录 | +| `httpDir` | `string` | `'src/http'` | HTTP 路由目录 | +| `httpPrefix` | `string` | `'/api'` | HTTP 路由前缀 | +| `cors` | `boolean \| CorsOptions` | - | CORS 配置 | | `onStart` | `(port) => void` | - | 启动回调 | | `onConnect` | `(conn) => void` | - | 连接回调 | | `onDisconnect` | `(conn) => void` | - | 断开回调 | +## HTTP 路由 + +支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。 + +### 文件路由 + +在 `httpDir` 目录下创建路由文件,自动映射为 HTTP 端点: + +``` +src/http/ +├── login.ts → POST /api/login +├── register.ts → POST /api/register +├── health.ts → GET /api/health (需设置 method: 'GET') +└── users/ + └── [id].ts → POST /api/users/:id (动态路由) +``` + +### 定义路由 + +使用 `defineHttp` 定义类型安全的路由处理器: + +```typescript +// src/http/login.ts +import { defineHttp } from '@esengine/server' + +interface LoginBody { + username: string + password: string +} + +export default defineHttp({ + method: 'POST', // 默认 POST,可选 GET/PUT/DELETE/PATCH + handler(req, res) { + const { username, password } = req.body + + // 验证凭证... + if (!isValid(username, password)) { + res.error(401, 'Invalid credentials') + return + } + + // 生成 token... + res.json({ token: '...', userId: '...' }) + } +}) +``` + +### 请求对象 (HttpRequest) + +```typescript +interface HttpRequest { + raw: IncomingMessage // Node.js 原始请求 + method: string // 请求方法 + path: string // 请求路径 + query: Record // 查询参数 + headers: Record + body: unknown // 解析后的 JSON 请求体 + ip: string // 客户端 IP +} +``` + +### 响应对象 (HttpResponse) + +```typescript +interface HttpResponse { + raw: ServerResponse // Node.js 原始响应 + status(code: number): HttpResponse // 设置状态码(链式) + header(name: string, value: string): HttpResponse // 设置头(链式) + json(data: unknown): void // 发送 JSON + text(data: string): void // 发送文本 + error(code: number, message: string): void // 发送错误 +} +``` + +### 使用示例 + +```typescript +// 完整的登录服务器示例 +import { createServer, defineHttp } from '@esengine/server' +import { createJwtAuthProvider, withAuth } from '@esengine/server/auth' + +const jwtProvider = createJwtAuthProvider({ + secret: process.env.JWT_SECRET!, + expiresIn: 3600 * 24, +}) + +const server = await createServer({ + port: 8080, + httpDir: 'src/http', + httpPrefix: '/api', + cors: true, +}) + +// 包装认证(WebSocket 连接验证 token) +const authServer = withAuth(server, { + provider: jwtProvider, + extractCredentials: (req) => { + const url = new URL(req.url, 'http://localhost') + return url.searchParams.get('token') + }, +}) + +await authServer.start() +// HTTP: http://localhost:8080/api/* +// WebSocket: ws://localhost:8080?token=xxx +``` + +### 内联路由 + +也可以直接在配置中定义路由(与文件路由合并,内联优先): + +```typescript +const server = await createServer({ + port: 8080, + http: { + '/health': { + GET: (req, res) => res.json({ status: 'ok' }), + }, + '/webhook': async (req, res) => { + // 接受所有方法 + await handleWebhook(req.body) + res.json({ received: true }) + }, + }, +}) +``` + ## Room 系统 Room 是游戏房间的基类,管理玩家和游戏状态。 diff --git a/packages/framework/network/CHANGELOG.md b/packages/framework/network/CHANGELOG.md index 7281a9eb..391cbeda 100644 --- a/packages/framework/network/CHANGELOG.md +++ b/packages/framework/network/CHANGELOG.md @@ -1,5 +1,12 @@ # @esengine/network +## 5.0.2 + +### Patch Changes + +- Updated dependencies []: + - @esengine/rpc@1.1.2 + ## 5.0.1 ### Patch Changes diff --git a/packages/framework/network/package.json b/packages/framework/network/package.json index 382030d9..84bca005 100644 --- a/packages/framework/network/package.json +++ b/packages/framework/network/package.json @@ -1,6 +1,6 @@ { "name": "@esengine/network", - "version": "5.0.1", + "version": "5.0.2", "description": "Network synchronization for multiplayer games", "esengine": { "plugin": true, diff --git a/packages/framework/rpc/CHANGELOG.md b/packages/framework/rpc/CHANGELOG.md index b18e6778..3f5a28aa 100644 --- a/packages/framework/rpc/CHANGELOG.md +++ b/packages/framework/rpc/CHANGELOG.md @@ -1,5 +1,49 @@ # @esengine/rpc +## 1.1.2 + +### Patch Changes + +- feat(server): add HTTP file-based routing support + + New feature that allows organizing HTTP routes in separate files, similar to API and message handlers: + + ```typescript + // src/http/login.ts + import { defineHttp } from '@esengine/server'; + + export default defineHttp<{ username: string; password: string }>({ + method: 'POST', + handler(req, res) { + const { username, password } = req.body; + // ... authentication logic + res.json({ token: '...', userId: '...' }); + } + }); + ``` + + Server configuration: + + ```typescript + const server = await createServer({ + port: 8080, + httpDir: 'src/http', // HTTP routes directory + httpPrefix: '/api', // Route prefix + cors: true + }); + ``` + + File naming convention: + - `login.ts` → POST /api/login + - `users/profile.ts` → POST /api/users/profile + - `users/[id].ts` → POST /api/users/:id (dynamic routes) + - Set `method: 'GET'` in defineHttp for GET requests + + Also includes: + - `defineHttp()` helper function for type-safe route definitions + - Support for merging file routes with inline `http` config + - RPC server now supports attaching to existing HTTP server via `server` option + ## 1.1.1 ### Patch Changes diff --git a/packages/framework/rpc/package.json b/packages/framework/rpc/package.json index aa1a6a9c..1226ce47 100644 --- a/packages/framework/rpc/package.json +++ b/packages/framework/rpc/package.json @@ -1,6 +1,6 @@ { "name": "@esengine/rpc", - "version": "1.1.1", + "version": "1.1.2", "description": "Elegant type-safe RPC library for ESEngine", "type": "module", "main": "./dist/index.js", diff --git a/packages/framework/rpc/src/server/index.ts b/packages/framework/rpc/src/server/index.ts index e08c4a84..aa7ee47d 100644 --- a/packages/framework/rpc/src/server/index.ts +++ b/packages/framework/rpc/src/server/index.ts @@ -4,6 +4,7 @@ */ import { WebSocketServer, WebSocket } from 'ws' +import type { Server as HttpServer } from 'node:http' import type { ProtocolDef, ApiNames, @@ -66,10 +67,19 @@ type MsgHandlers

= { */ export interface ServeOptions

{ /** - * @zh 监听端口 - * @en Listen port + * @zh 监听端口(与 server 二选一) + * @en Listen port (mutually exclusive with server) */ - port: number + port?: number + + /** + * @zh 已有的 HTTP 服务器(与 port 二选一) + * @en Existing HTTP server (mutually exclusive with port) + * + * @zh 使用此选项可以在同一端口同时支持 HTTP 和 WebSocket + * @en Use this option to support both HTTP and WebSocket on the same port + */ + server?: HttpServer /** * @zh API 处理器 @@ -280,7 +290,16 @@ export function serve

( async start() { return new Promise((resolve) => { - wss = new WebSocketServer({ port: options.port }) + // 根据配置创建 WebSocketServer + if (options.server) { + // 附加到已有的 HTTP 服务器 + wss = new WebSocketServer({ server: options.server }) + } else if (options.port) { + // 独立创建 + wss = new WebSocketServer({ port: options.port }) + } else { + throw new Error('Either port or server must be provided') + } wss.on('connection', async (ws, req) => { const id = String(++connIdCounter) @@ -318,10 +337,16 @@ export function serve

( await options.onConnect?.(conn) }) - wss.on('listening', () => { - options.onStart?.(options.port) + // 如果使用已有的 HTTP 服务器,WebSocketServer 不会触发 listening 事件 + if (options.server) { + options.onStart?.(0) // 端口由 HTTP 服务器管理 resolve() - }) + } else { + wss.on('listening', () => { + options.onStart?.(options.port!) + resolve() + }) + } }) }, diff --git a/packages/framework/server/CHANGELOG.md b/packages/framework/server/CHANGELOG.md index 28813347..66b75689 100644 --- a/packages/framework/server/CHANGELOG.md +++ b/packages/framework/server/CHANGELOG.md @@ -1,5 +1,54 @@ # @esengine/server +## 4.1.0 + +### Minor Changes + +- feat(server): add HTTP file-based routing support + + New feature that allows organizing HTTP routes in separate files, similar to API and message handlers: + + ```typescript + // src/http/login.ts + import { defineHttp } from '@esengine/server'; + + export default defineHttp<{ username: string; password: string }>({ + method: 'POST', + handler(req, res) { + const { username, password } = req.body; + // ... authentication logic + res.json({ token: '...', userId: '...' }); + } + }); + ``` + + Server configuration: + + ```typescript + const server = await createServer({ + port: 8080, + httpDir: 'src/http', // HTTP routes directory + httpPrefix: '/api', // Route prefix + cors: true + }); + ``` + + File naming convention: + - `login.ts` → POST /api/login + - `users/profile.ts` → POST /api/users/profile + - `users/[id].ts` → POST /api/users/:id (dynamic routes) + - Set `method: 'GET'` in defineHttp for GET requests + + Also includes: + - `defineHttp()` helper function for type-safe route definitions + - Support for merging file routes with inline `http` config + - RPC server now supports attaching to existing HTTP server via `server` option + +### Patch Changes + +- Updated dependencies []: + - @esengine/rpc@1.1.2 + ## 4.0.0 ### Patch Changes diff --git a/packages/framework/server/package.json b/packages/framework/server/package.json index 507f801d..35660ba4 100644 --- a/packages/framework/server/package.json +++ b/packages/framework/server/package.json @@ -1,6 +1,6 @@ { "name": "@esengine/server", - "version": "4.0.0", + "version": "4.1.0", "description": "Game server framework for ESEngine with file-based routing", "type": "module", "main": "./dist/index.js", diff --git a/packages/framework/server/src/core/server.ts b/packages/framework/server/src/core/server.ts index 41ea8e92..c319cad2 100644 --- a/packages/framework/server/src/core/server.ts +++ b/packages/framework/server/src/core/server.ts @@ -4,6 +4,7 @@ */ import * as path from 'node:path' +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http' import { serve, type RpcServer } from '@esengine/rpc/server' import { rpc } from '@esengine/rpc' import type { @@ -14,18 +15,23 @@ import type { MsgContext, LoadedApiHandler, LoadedMsgHandler, + LoadedHttpHandler, } from '../types/index.js' -import { loadApiHandlers, loadMsgHandlers } from '../router/loader.js' +import type { HttpRoutes, HttpHandler } from '../http/types.js' +import { loadApiHandlers, loadMsgHandlers, loadHttpHandlers } from '../router/loader.js' import { RoomManager, type RoomClass, type Room } from '../room/index.js' +import { createHttpRouter } from '../http/router.js' /** * @zh 默认配置 * @en Default configuration */ -const DEFAULT_CONFIG: Required> = { +const DEFAULT_CONFIG: Required> & { httpDir: string; httpPrefix: string } = { port: 3000, apiDir: 'src/api', msgDir: 'src/msg', + httpDir: 'src/http', + httpPrefix: '/api', tickRate: 20, } @@ -56,12 +62,53 @@ export async function createServer(config: ServerConfig = {}): Promise 0) { console.log(`[Server] Loaded ${apiHandlers.length} API handlers`) } if (msgHandlers.length > 0) { console.log(`[Server] Loaded ${msgHandlers.length} message handlers`) } + if (httpHandlers.length > 0) { + console.log(`[Server] Loaded ${httpHandlers.length} HTTP handlers`) + } + + // 合并 HTTP 路由(文件路由 + 内联路由) + const mergedHttpRoutes: HttpRoutes = {} + + // 先添加文件路由 + for (const handler of httpHandlers) { + const existingRoute = mergedHttpRoutes[handler.route] + if (existingRoute && typeof existingRoute !== 'function') { + (existingRoute as Record)[handler.method] = handler.definition.handler + } else { + mergedHttpRoutes[handler.route] = { + [handler.method]: handler.definition.handler, + } + } + } + + // 再添加内联路由(覆盖文件路由) + if (config.http) { + for (const [route, handlerOrMethods] of Object.entries(config.http)) { + if (typeof handlerOrMethods === 'function') { + mergedHttpRoutes[route] = handlerOrMethods + } else { + const existing = mergedHttpRoutes[route] + if (existing && typeof existing !== 'function') { + Object.assign(existing, handlerOrMethods) + } else { + mergedHttpRoutes[route] = handlerOrMethods + } + } + } + } + + const hasHttpRoutes = Object.keys(mergedHttpRoutes).length > 0 // 动态构建协议 const apiDefs: Record> = { @@ -90,6 +137,7 @@ export async function createServer(config: ServerConfig = {}): Promise | null = null let rpcServer: RpcServer> | null = null + let httpServer: HttpServer | null = null // 房间管理器(立即初始化,以便 define() 可在 start() 前调用) const roomManager = new RoomManager((conn, type, data) => { @@ -200,26 +248,68 @@ export async function createServer(config: ServerConfig = {}): Promise ({}), - onStart: (p) => { - console.log(`[Server] Started on ws://localhost:${p}`) - opts.onStart?.(p) - }, - onConnect: async (conn) => { - await config.onConnect?.(conn as ServerConnection) - }, - onDisconnect: async (conn) => { - // 玩家断线时自动离开房间 - await roomManager?.leave(conn.id, 'disconnected') - await config.onDisconnect?.(conn as ServerConnection) - }, - api: apiHandlersObj as any, - msg: msgHandlersObj as any, - }) + // 如果有 HTTP 路由,创建 HTTP 服务器 + if (hasHttpRoutes) { + const httpRouter = createHttpRouter(mergedHttpRoutes, config.cors ?? true) - await rpcServer.start() + httpServer = createHttpServer(async (req, res) => { + // 先尝试 HTTP 路由 + const handled = await httpRouter(req, res) + if (!handled) { + // 未匹配的请求返回 404 + res.statusCode = 404 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'Not Found' })) + } + }) + + // 使用 HTTP 服务器创建 RPC + rpcServer = serve(protocol, { + server: httpServer, + createConnData: () => ({}), + onStart: () => { + console.log(`[Server] Started on http://localhost:${opts.port}`) + opts.onStart?.(opts.port) + }, + onConnect: async (conn) => { + await config.onConnect?.(conn as ServerConnection) + }, + onDisconnect: async (conn) => { + await roomManager?.leave(conn.id, 'disconnected') + await config.onDisconnect?.(conn as ServerConnection) + }, + api: apiHandlersObj as any, + msg: msgHandlersObj as any, + }) + + await rpcServer.start() + + // 启动 HTTP 服务器 + await new Promise((resolve) => { + httpServer!.listen(opts.port, () => resolve()) + }) + } else { + // 仅 WebSocket 模式 + rpcServer = serve(protocol, { + port: opts.port, + createConnData: () => ({}), + onStart: (p) => { + console.log(`[Server] Started on ws://localhost:${p}`) + opts.onStart?.(p) + }, + onConnect: async (conn) => { + await config.onConnect?.(conn as ServerConnection) + }, + onDisconnect: async (conn) => { + await roomManager?.leave(conn.id, 'disconnected') + await config.onDisconnect?.(conn as ServerConnection) + }, + api: apiHandlersObj as any, + msg: msgHandlersObj as any, + }) + + await rpcServer.start() + } // 启动 tick 循环 if (opts.tickRate > 0) { @@ -238,6 +328,15 @@ export async function createServer(config: ServerConfig = {}): Promise((resolve, reject) => { + httpServer!.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + httpServer = null + } }, broadcast(name, data) { diff --git a/packages/framework/server/src/helpers/define.ts b/packages/framework/server/src/helpers/define.ts index 42035238..d5fea51d 100644 --- a/packages/framework/server/src/helpers/define.ts +++ b/packages/framework/server/src/helpers/define.ts @@ -1,9 +1,9 @@ /** - * @zh API 和消息定义助手 - * @en API and message definition helpers + * @zh API、消息和 HTTP 定义助手 + * @en API, message, and HTTP definition helpers */ -import type { ApiDefinition, MsgDefinition } from '../types/index.js' +import type { ApiDefinition, MsgDefinition, HttpDefinition } from '../types/index.js' /** * @zh 定义 API 处理器 @@ -49,3 +49,33 @@ export function defineMsg>( ): MsgDefinition { return definition } + +/** + * @zh 定义 HTTP 路由处理器 + * @en Define HTTP route handler + * + * @example + * ```typescript + * // src/http/login.ts + * import { defineHttp } from '@esengine/server' + * + * interface LoginBody { + * username: string + * password: string + * } + * + * export default defineHttp({ + * method: 'POST', + * handler(req, res) { + * const { username, password } = req.body + * // ... validate credentials + * res.json({ token: '...', userId: '...' }) + * } + * }) + * ``` + */ +export function defineHttp( + definition: HttpDefinition +): HttpDefinition { + return definition +} diff --git a/packages/framework/server/src/http/index.ts b/packages/framework/server/src/http/index.ts new file mode 100644 index 00000000..80cc59eb --- /dev/null +++ b/packages/framework/server/src/http/index.ts @@ -0,0 +1,7 @@ +/** + * @zh HTTP 模块导出 + * @en HTTP module exports + */ + +export * from './types.js'; +export { createHttpRouter } from './router.js'; diff --git a/packages/framework/server/src/http/router.ts b/packages/framework/server/src/http/router.ts new file mode 100644 index 00000000..ebb75e0b --- /dev/null +++ b/packages/framework/server/src/http/router.ts @@ -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 { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + + // 解析查询参数 + const query: Record = {}; + 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, + body, + ip, + }; +} + +/** + * @zh 解析请求体 + * @en Parse request body + */ +function parseBody(req: IncomingMessage): Promise { + 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 = {}; + 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 { + 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; + } + }; +} diff --git a/packages/framework/server/src/http/types.ts b/packages/framework/server/src/http/types.ts new file mode 100644 index 00000000..11ee0e34 --- /dev/null +++ b/packages/framework/server/src/http/types.ts @@ -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; + + /** + * @zh 请求头 + * @en Request headers + */ + headers: Record; + + /** + * @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; + +/** + * @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; + +/** + * @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; +} diff --git a/packages/framework/server/src/index.ts b/packages/framework/server/src/index.ts index d23c5a7d..5a42dd55 100644 --- a/packages/framework/server/src/index.ts +++ b/packages/framework/server/src/index.ts @@ -30,7 +30,7 @@ export { createServer } from './core/server.js' // Helpers -export { defineApi, defineMsg } from './helpers/define.js' +export { defineApi, defineMsg, defineHttp } from './helpers/define.js' // Room System export { Room, type RoomOptions } from './room/Room.js' @@ -46,7 +46,19 @@ export type { MsgContext, ApiDefinition, MsgDefinition, + HttpDefinition, + HttpMethod, } from './types/index.js' +// HTTP +export { createHttpRouter } from './http/router.js' +export type { + HttpRequest, + HttpResponse, + HttpHandler, + HttpRoutes, + CorsOptions, +} from './http/types.js' + // Re-export useful types from @esengine/rpc export { RpcError, ErrorCode } from '@esengine/rpc' diff --git a/packages/framework/server/src/router/loader.ts b/packages/framework/server/src/router/loader.ts index e22d4427..7e785d0a 100644 --- a/packages/framework/server/src/router/loader.ts +++ b/packages/framework/server/src/router/loader.ts @@ -6,7 +6,15 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { pathToFileURL } from 'node:url' -import type { ApiDefinition, MsgDefinition, LoadedApiHandler, LoadedMsgHandler } from '../types/index.js' +import type { + ApiDefinition, + MsgDefinition, + HttpDefinition, + LoadedApiHandler, + LoadedMsgHandler, + LoadedHttpHandler, + HttpMethod, +} from '../types/index.js' /** * @zh 将文件名转换为 API/消息名称 @@ -110,3 +118,106 @@ export async function loadMsgHandlers(msgDir: string): Promise { + if (!fs.existsSync(dir)) { + return [] + } + + const files: Array<{ filePath: string; relativePath: string }> = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + files.push(...scanDirectoryRecursive(fullPath, baseDir)) + } else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) { + if (entry.name.startsWith('_') || entry.name.startsWith('index.')) { + continue + } + const relativePath = path.relative(baseDir, fullPath) + files.push({ filePath: fullPath, relativePath }) + } + } + + return files +} + +/** + * @zh 将文件路径转换为路由路径 + * @en Convert file path to route path + * + * @example + * 'login.ts' -> '/login' + * 'users/profile.ts' -> '/users/profile' + * 'users/[id].ts' -> '/users/:id' + */ +function filePathToRoute(relativePath: string, prefix: string): string { + let route = relativePath + .replace(/\.(ts|js|mts|mjs)$/, '') + .replace(/\\/g, '/') + .replace(/\[([^\]]+)\]/g, ':$1') + + if (!route.startsWith('/')) { + route = '/' + route + } + + const fullRoute = prefix.endsWith('/') + ? prefix.slice(0, -1) + route + : prefix + route + + return fullRoute +} + +/** + * @zh 加载 HTTP 路由处理器 + * @en Load HTTP route handlers + * + * @example + * ```typescript + * // Directory structure: + * // src/http/ + * // login.ts -> POST /api/login + * // register.ts -> POST /api/register + * // users/ + * // [id].ts -> GET /api/users/:id + * + * const handlers = await loadHttpHandlers('src/http', '/api') + * ``` + */ +export async function loadHttpHandlers( + httpDir: string, + prefix: string = '/api' +): Promise { + const files = scanDirectoryRecursive(httpDir) + const handlers: LoadedHttpHandler[] = [] + + for (const { filePath, relativePath } of files) { + try { + const fileUrl = pathToFileURL(filePath).href + const module = await import(fileUrl) + const definition = module.default as HttpDefinition + + if (definition && typeof definition.handler === 'function') { + const route = filePathToRoute(relativePath, prefix) + const method: HttpMethod = definition.method ?? 'POST' + + handlers.push({ + route, + method, + path: filePath, + definition, + }) + } + } catch (err) { + console.warn(`[Server] Failed to load HTTP handler: ${filePath}`, err) + } + } + + return handlers +} diff --git a/packages/framework/server/src/types/index.ts b/packages/framework/server/src/types/index.ts index b6937f02..e1663067 100644 --- a/packages/framework/server/src/types/index.ts +++ b/packages/framework/server/src/types/index.ts @@ -4,6 +4,7 @@ */ import type { Connection, ProtocolDef } from '@esengine/rpc' +import type { HttpRoutes, CorsOptions, HttpRequest, HttpResponse } from '../http/types.js' // ============================================================================ // Server Config @@ -35,6 +36,29 @@ export interface ServerConfig { */ msgDir?: string + /** + * @zh HTTP 路由目录路径 + * @en HTTP routes directory path + * @default 'src/http' + * + * @zh 文件命名规则: + * - `login.ts` → POST /api/login + * - `users/[id].ts` → /api/users/:id + * - `health.ts` (method: 'GET') → GET /api/health + * @en File naming convention: + * - `login.ts` → POST /api/login + * - `users/[id].ts` → /api/users/:id + * - `health.ts` (method: 'GET') → GET /api/health + */ + httpDir?: string + + /** + * @zh HTTP 路由前缀 + * @en HTTP routes prefix + * @default '/api' + */ + httpPrefix?: string + /** * @zh 游戏 Tick 速率 (每秒) * @en Game tick rate (per second) @@ -42,6 +66,19 @@ export interface ServerConfig { */ tickRate?: number + /** + * @zh HTTP 路由配置(内联定义,与 httpDir 文件路由合并) + * @en HTTP routes configuration (inline definition, merged with httpDir file routes) + */ + http?: HttpRoutes + + /** + * @zh CORS 配置 + * @en CORS configuration + * @default true + */ + cors?: CorsOptions | boolean + /** * @zh 服务器启动回调 * @en Server start callback @@ -232,3 +269,80 @@ export interface LoadedMsgHandler { path: string definition: MsgDefinition } + +// ============================================================================ +// HTTP Definition +// ============================================================================ + +/** + * @zh HTTP 请求方法 + * @en HTTP request method + */ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + +/** + * @zh HTTP 定义选项 + * @en HTTP definition options + * + * @example + * ```typescript + * // src/http/login.ts + * import { defineHttp } from '@esengine/server' + * + * export default defineHttp({ + * method: 'POST', + * handler: async (req, res) => { + * const { username, password } = req.body + * // ... authentication logic + * res.json({ token: '...', userId: '...' }) + * } + * }) + * ``` + */ +export interface HttpDefinition { + /** + * @zh 请求方法 + * @en Request method + * @default 'POST' + */ + method?: HttpMethod + + /** + * @zh 处理函数 + * @en Handler function + */ + handler: ( + req: HttpRequest & { body: TBody }, + res: HttpResponse + ) => void | Promise +} + +/** + * @zh 已加载的 HTTP 处理器 + * @en Loaded HTTP handler + */ +export interface LoadedHttpHandler { + /** + * @zh 路由路径(如 /api/login) + * @en Route path (e.g., /api/login) + */ + route: string + + /** + * @zh 请求方法 + * @en Request method + */ + method: HttpMethod + + /** + * @zh 源文件路径 + * @en Source file path + */ + path: string + + /** + * @zh 处理器定义 + * @en Handler definition + */ + definition: HttpDefinition +} diff --git a/packages/framework/transaction/CHANGELOG.md b/packages/framework/transaction/CHANGELOG.md index 17906493..fb4c4bc6 100644 --- a/packages/framework/transaction/CHANGELOG.md +++ b/packages/framework/transaction/CHANGELOG.md @@ -1,5 +1,12 @@ # @esengine/transaction +## 2.0.6 + +### Patch Changes + +- Updated dependencies []: + - @esengine/server@4.1.0 + ## 2.0.5 ### Patch Changes diff --git a/packages/framework/transaction/package.json b/packages/framework/transaction/package.json index 0b4740ea..33423b0e 100644 --- a/packages/framework/transaction/package.json +++ b/packages/framework/transaction/package.json @@ -1,6 +1,6 @@ { "name": "@esengine/transaction", - "version": "2.0.5", + "version": "2.0.6", "description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务", "type": "module", "main": "./dist/index.js",