diff --git a/.vscode/launch.json b/.vscode/launch.json index 6cc9e7c..e8452fb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ { "type": "node", "request": "launch", - "name": "Launch Program", + "name": "Web server", "skipFiles": ["/**"], "program": "${workspaceFolder}/bin/koapp.js", "args": ["http", "-p", "8080"] @@ -15,10 +15,34 @@ { "type": "node", "request": "launch", - "name": "Test Program", + "name": "Test web server", "skipFiles": ["/**"], "program": "${workspaceFolder}/tests/bootstrap.js", "args": [] + }, + { + "type": "node", + "request": "launch", + "name": "Example: Socket Client", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/examples/socket.client.js", + "args": [] + }, + { + "type": "node", + "request": "launch", + "name": "Example: Socket Server", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/examples/socket.server.js", + "args": [] + }, + { + "type": "node", + "request": "launch", + "name": "Example: WebSocket Server", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/examples/websocket.server.js", + "args": [] } ] } diff --git a/examples/api.router.js b/examples/api.router.js new file mode 100644 index 0000000..caf709e --- /dev/null +++ b/examples/api.router.js @@ -0,0 +1,34 @@ +const { success } = require('../src/response'); + +const { Router } = require('..'); +const { debug } = require('@axiosleo/cli-tool'); + +const root = new Router(null, { + middlewares: [async (context) => { + debug.log(`[${context.app_id}] ${context.method}: ${context.router.pathinfo}`); + }], + afters: [async (context) => { + debug.log({ + query: context.query, + body: context.body, + test: context.params.id + }); + }] +}); + +root.get('/api/test/{:id}', async (context) => { + success({ + query: context.query, + body: context.body, + test: context.params.id + }); +}); + +root.any('/***', async (context) => { + success({ + query: context.query, + body: context.body + }); +}); + +module.exports = root; diff --git a/examples/socket.client.js b/examples/socket.client.js new file mode 100644 index 0000000..ce31abd --- /dev/null +++ b/examples/socket.client.js @@ -0,0 +1,41 @@ +'use strict'; + +const { debug } = require('@axiosleo/cli-tool'); +const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { SocketClient } = require('..'); + +let client = null; + +/** + * @returns {SocketClient} + */ +function getClient() { + if (!client) { + client = new SocketClient(); + } + return client; +} + +async function main() { + try { + const client = getClient(); + await client.send('get', '/api/test/123', { + test: 123 + }, { + data: { + t: 1 + } + }); + } catch (err) { + debug.log('error', err.code); + } + + await _sleep(3000); + process.nextTick(main); +} + +main().then(() => { + // debug.log('done'); +}).catch((err) => { + debug.log(err); +}); diff --git a/examples/socket.server.js b/examples/socket.server.js new file mode 100644 index 0000000..2e2ad9a --- /dev/null +++ b/examples/socket.server.js @@ -0,0 +1,16 @@ +'use strict'; + +const SocketApplication = require('../src/apps/socket'); + +const root = require('./api.router'); + +const app = new SocketApplication({ + routers: [root], + ping: { + open: true, + interval: 1000 * 10, + data: 'this is a ping message.' + } +}); + +app.start(); diff --git a/examples/socket.web.html b/examples/socket.web.html new file mode 100644 index 0000000..590e79b --- /dev/null +++ b/examples/socket.web.html @@ -0,0 +1,192 @@ + + + + + + WebSocket Example + + + +
+

WebSocket Example

+ +
Disconnected
+ +
+ + + +
+ +
+ +
+ + +
+
+ + + + diff --git a/examples/websocket.server.js b/examples/websocket.server.js new file mode 100644 index 0000000..8831e7f --- /dev/null +++ b/examples/websocket.server.js @@ -0,0 +1,14 @@ +const { WebSocketApplication } = require('../src/apps'); +const root = require('./api.router'); + +const app = new WebSocketApplication({ + routers: [root], + port: 8081, + ping: { + open: false, + interval: 1000 * 3, + data: 'this is a ping message' + } +}); + +app.start(); diff --git a/index.d.ts b/index.d.ts index 52c43f7..1e9e959 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,9 +5,19 @@ import { IncomingHttpHeaders } from "http"; import * as Koa from "koa"; import * as session from "koa-session"; import * as KoaStaticServer from "koa-static-server"; +import type { Socket } from "net"; import { Transform } from "stream"; import { ErrorMessages, Rules, Validator } from "validatorjs"; +import type { ServerOptions, WebSocket } from "ws"; +// ======================================== +// Status Code Types +// ======================================== + +/** + * Predefined status codes with format "code;message" + * Used for standardized API responses + */ type StatusCode = | string | "000;Unknown Error" @@ -21,6 +31,14 @@ type StatusCode = | "501;Failed" | "409;Data Already Exists"; +// ======================================== +// HTTP Method Types +// ======================================== + +/** + * HTTP methods supported by the framework + * Includes both uppercase and lowercase variants + */ type HttpMethod = | "ANY" | "GET" @@ -44,38 +62,104 @@ type HttpMethod = | "connect" | string; -export function response( - data: unknown, +// ======================================== +// Response Functions +// ======================================== + +/** + * Send a response with data, status code, and optional headers + * @template T Type of response data + * @param data Response data + * @param code Status code in format "code;message" + * @param httpStatus HTTP status code (default: 200) + * @param headers Optional response headers + */ +export function response( + data: T, code?: StatusCode, httpStatus?: number, headers?: Record ): void; -export function result( - data: unknown, + +/** + * Send a result response with data and optional headers + * @template T Type of response data + * @param data Response data + * @param httpStatus HTTP status code (default: 200) + * @param headers Optional response headers + */ +export function result( + data: T, httpStatus?: number, headers?: Record ): void; -export function success(data?: unknown, headers?: Record): void; -export function failed( - data?: unknown, + +/** + * Send a success response with optional data and headers + * @template T Type of response data + * @param data Optional response data + * @param headers Optional response headers + */ +export function success( + data?: T, + headers?: Record +): void; + +/** + * Send a failed response with error data and status + * @template T Type of response data + * @param data Error data + * @param code Status code in format "code;message" + * @param httpStatus HTTP status code (default: 500) + * @param headers Optional response headers + */ +export function failed( + data?: T, code?: StatusCode, httpStatus?: number, headers?: Record ): void; + +/** + * Send an error response with HTTP status and message + * @param httpStatus HTTP status code + * @param msg Error message + * @param headers Optional response headers + */ export function error( httpStatus: number, msg: string, headers?: Record ): void; + +/** + * Log data to console (development utility) + * @param data Data to log + */ export function log(...data: any): void; +// ======================================== +// HTTP Response Classes +// ======================================== + +/** + * Configuration for HTTP response + */ export interface HttpResponseConfig { + /** HTTP status code */ status?: number; + /** Response headers */ headers?: IncomingHttpHeaders; + /** Response data */ data?: unknown; + /** Response format */ format?: "json" | "text"; } +/** + * HTTP response class for structured responses + * Extends Error to work with error handling middleware + */ export declare class HttpResponse extends Error { public readonly status: number; public readonly headers: IncomingHttpHeaders; @@ -83,6 +167,10 @@ export declare class HttpResponse extends Error { constructor(config?: HttpResponseConfig); } +/** + * HTTP error class for error responses + * Extends Error to work with error handling middleware + */ export declare class HttpError extends Error { public readonly status: number; public readonly headers: IncomingHttpHeaders; @@ -94,21 +182,28 @@ export declare class HttpError extends Error { ); } +// ======================================== +// Controller Interface and Class +// ======================================== + +/** + * Interface defining controller response methods + */ interface ControllerInterface { - response( - data: unknown, + response( + data: T, code?: StatusCode, status?: number, headers?: Record ): void; - result( - data: unknown, + result( + data: T, status?: number, headers?: Record ): void; - success(data?: unknown, headers?: Record): void; - failed( - data?: unknown, + success(data?: T, headers?: Record): void; + failed( + data?: T, code?: StatusCode, status?: number, headers?: Record @@ -117,21 +212,25 @@ interface ControllerInterface { log(...data: any): void; } +/** + * Base controller class providing response methods + * Implements standard response patterns for API endpoints + */ export declare class Controller implements ControllerInterface { - response( - data: unknown, + response( + data: T, code?: StatusCode, status?: number, headers?: Record ): void; - result( - data: unknown, + result( + data: T, status?: number, headers?: Record ): void; - success(data?: unknown, headers?: Record): void; - failed( - data?: unknown, + success(data?: T, headers?: Record): void; + failed( + data?: T, code?: StatusCode, status?: number, headers?: Record @@ -140,96 +239,428 @@ export declare class Controller implements ControllerInterface { log(...data: any): void; } +// ======================================== +// Validation Types +// ======================================== + +/** + * Configuration for request validation + */ interface ValidatorConfig { + /** Validation rules */ rules: Rules; + /** Custom error messages */ messages?: ErrorMessages; } +/** + * Validators for different parts of the request + */ interface RouterValidator { + /** Path parameter validation */ params?: ValidatorConfig; + /** Query parameter validation */ query?: ValidatorConfig; + /** Request body validation */ body?: ValidatorConfig; } -interface RouterInfo { +// ======================================== +// Router Types +// ======================================== + +/** + * Information about a matched route + * @template TParams Type of route parameters (defaults to Record) + * @template TBody Type of request body (defaults to any) + * @template TQuery Type of query parameters (defaults to any) + * + * @example + * ```typescript + * // RouterInfo is now framework-agnostic, using base AppContext + * interface UserParams { id: string; action: 'view' | 'edit'; } + * interface UserBody { name: string; email: string; } + * interface UserQuery { include?: 'profile'; } + * + * type UserRouterInfo = RouterInfo; + * + * // Can be used with any context type that extends AppContext + * const routerInfo: UserRouterInfo = { + * pathinfo: '/user/{:id}/{:action}', + * validators: {}, + * middlewares: [], // ContextHandler>[] + * handlers: [], // ContextHandler>[] + * afters: [], // ContextHandler>[] + * methods: ['POST', 'PUT'], + * params: { id: '123', action: 'edit' } + * }; + * ``` + */ +interface RouterInfo< + TParams = Record, + TBody = any, + TQuery = any +> { + /** Route path pattern */ pathinfo: string; + /** Route validators */ validators: RouterValidator; - middlewares: ContextHandler[]; - handlers: ContextHandler[]; - afters: ContextHandler[]; + /** Middleware functions */ + middlewares: ContextHandler>[]; + /** Handler functions */ + handlers: ContextHandler>[]; + /** After middleware functions */ + afters: ContextHandler>[]; + /** Supported HTTP methods */ methods: string[]; - params: { - [key: string]: string; - }; + /** Extracted path parameters */ + params: TParams; } -interface AppContext extends Context { - app: Application; - app_id: string; - method?: string; - pathinfo?: string; - config: AppConfiguration; - request_id: string; - router?: RouterInfo | null; -} +// ======================================== +// Context Types +// ======================================== +// ======================================== +// Server-Sent Events Types +// ======================================== + +/** + * Server-sent event data structure + */ interface IKoaSSEvent { + /** Event ID */ id?: number; + /** Event data */ data?: string | object; + /** Event type */ event?: string; } +/** + * Server-sent events interface extending Transform stream + */ interface IKoaSSE extends Transform { + /** Send SSE event */ send(data: IKoaSSEvent | string): void; + /** Send keep-alive ping */ keepAlive(): void; + /** Close SSE connection */ close(): void; } -interface KoaContext extends AppContext { +/** + * Base application context interface + * @template TParams Type of route parameters (defaults to Record) + * @template TBody Type of request body (defaults to any) + * @template TQuery Type of query parameters (defaults to any) + * + * @example + * ```typescript + * // Basic usage with default types + * interface MyContext extends AppContext {} + * + * // Usage with specific types + * interface UserParams { id: string; action: 'view' | 'edit'; } + * interface UserBody { name: string; email: string; } + * interface UserQuery { include?: 'profile' | 'settings'; } + * + * interface UserContext extends AppContext {} + * + * // In route handler + * const handler = async (context: UserContext) => { + * // context.router is now fully typed + * const routerParams = context.router.params; // UserParams + * const handlers = context.router.handlers; // ContextHandler>[] + * }; + * ``` + */ +interface AppContext< + TParams = Record, + TBody = any, + TQuery = any +> extends Context { + app: + | KoaApplication + | SocketApplication + | WebSocketApplication + | Application + | null; + app_id: string; + method: string; + pathinfo: string; + request_id?: string; + router?: RouterInfo | null; +} + +/** + * Koa-specific context extending AppContext + * @template TParams Type of route parameters (defaults to Record) + * @template TBody Type of request body (defaults to any) + * @template TQuery Type of query parameters (defaults to any) + * + * @example + * ```typescript + * // Define specific parameter and body types + * interface ProductParams { + * id: string; + * category: string; + * } + * + * interface CreateProductBody { + * name: string; + * price: number; + * description?: string; + * tags?: string[]; + * } + * + * interface ProductQuery { + * sort?: 'asc' | 'desc'; + * limit?: number; + * include?: 'details' | 'reviews' | 'images'; + * } + * + * // Create fully typed context + * type ProductContext = KoaContext; + * + * // Use in route handler with full type safety + * router.post('/product/{:id}/category/{:category}', async (context: ProductContext) => { + * // Full type safety for params + * const productId = context.params.id; // string + * const category = context.params.category; // string + * + * // Type-safe body access - TypeScript will enforce required fields + * const productName = context.body.name; // string + * const price = context.body.price; // number + * const desc = context.body.description; // string | undefined + * const tags = context.body.tags; // string[] | undefined + * + * // Type-safe query access + * const sortOrder = context.query.sort; // 'asc' | 'desc' | undefined + * const limit = context.query.limit; // number | undefined + * const include = context.query.include; // 'details' | 'reviews' | 'images' | undefined + * + * // TypeScript will catch type errors at compile time + * // const invalid = context.body.invalidField; // ❌ TypeScript error + * // const wrongType = context.query.sort === 'invalid'; // ❌ TypeScript error + * }); + * + * // Partial typing - only specify what you need + * type SimpleContext = KoaContext<{}, CreateProductBody>; // Only body typed + * type ParamsOnlyContext = KoaContext; // Only params typed + * type QueryOnlyContext = KoaContext<{}, any, ProductQuery>; // Only query typed + * + * // Real-world example: User management API + * interface UserParams { id: string; } + * interface UpdateUserBody { + * name?: string; + * email?: string; + * role?: 'admin' | 'user'; + * } + * interface UserQuery { + * expand?: 'profile' | 'permissions'; + * format?: 'json' | 'xml'; + * } + * + * const userRouter = new Router>(); + * + * userRouter.put('/user/{:id}', async (context) => { + * // All properties are fully typed with IntelliSense support + * const userId = context.params.id; + * const updates = context.body; // UpdateUserBody + * const options = context.query; // UserQuery + * + * // Type-safe validation + * if (updates.role && !['admin', 'user'].includes(updates.role)) { + * // This would be caught at compile time due to literal types + * } + * }); + * ``` + */ +interface KoaContext< + TParams = Record, + TBody = any, + TQuery = any +> extends AppContext { + /** Route parameters */ + params?: TParams; + /** Application configuration */ + config?: AppConfiguration; + /** Koa context with optional SSE support */ koa: Koa.ParameterizedContext & { sse?: IKoaSSE }; - method: HttpMethod; + /** Request URL */ url: string; - // eslint-disable-next-line no-use-before-define - router?: RouterInfo | null; - access_key_id?: string; - app_key?: string; - params?: any; - body?: any; + /** Request body */ + body?: TBody; + /** Uploaded file */ file?: File | null; + /** Uploaded files array */ files?: File[]; - query?: any; + /** Query parameters */ + query?: TQuery; + /** Request headers */ headers?: IncomingHttpHeaders; + /** Response object */ response?: HttpResponse | HttpError; } -type ContextHandler = (context: T) => Promise; +/** + * Context handler function type + * @template T Context type extending AppContext + */ +type ContextHandler = AppContext> = ( + context: T +) => Promise; + +// ======================================== +// Router Class +// ======================================== -interface RouterOptions { +/** + * Router options for configuration + * @template T Context type extending AppContext + * + * @example + * ```typescript + * // RouterOptions now uses base AppContext, making it framework-agnostic + * interface UserParams { id: string; } + * interface UserBody { name: string; } + * interface UserQuery { format?: 'json' | 'xml'; } + * + * type UserContext = AppContext; + * + * const routerOptions: RouterOptions = { + * method: 'POST', + * middlewares: [ + * async (context) => { + * // context is typed as UserContext + * console.log(`Processing user ${context.params?.id}`); + * } + * ], + * handlers: [ + * async (context) => { + * // Full type safety + * const userId = context.params?.id; // string | undefined + * const userName = context.body?.name; // string | undefined + * const format = context.query?.format; // 'json' | 'xml' | undefined + * } + * ] + * }; + * ``` + */ +interface RouterOptions = AppContext> { + /** Default HTTP method */ method?: HttpMethod; + /** Route handlers */ handlers?: ContextHandler[]; + /** Middleware functions */ middlewares?: ContextHandler[]; + /** After middleware functions */ afters?: ContextHandler[]; + /** Route description */ intro?: string; - routers?: Router[]; + /** Sub-routers */ + routers?: Router[]; + /** Route validators */ validators?: RouterValidator; } -export class Router { +/** + * Router class for defining API routes and middleware + * @template T Context type extending AppContext (can be KoaContext, SocketContext, etc.) + * + * @example + * ```typescript + * // Basic router usage + * const router = new Router(); + * + * // Router with typed params, body, and query using AppContext + * interface UserParams { + * id: string; + * action: 'view' | 'edit' | 'delete'; + * } + * + * interface UpdateUserBody { + * name?: string; + * email?: string; + * age?: number; + * } + * + * interface UserQuery { + * include?: 'profile' | 'settings'; + * format?: 'json' | 'xml'; + * } + * + * // Can use base AppContext + * type UserContext = AppContext; + * const userRouter = new Router(); + * + * // Or specific implementations like KoaContext + * type KoaUserContext = KoaContext; + * const koaRouter = new Router(); + * + * // Or SocketContext + * type SocketUserContext = SocketContext; + * const socketRouter = new Router(); + * + * userRouter.post('/user/{:id}/{:action}', async (context) => { + * // context.params is fully typed + * const userId = context.params?.id; // string | undefined + * const action = context.params?.action; // 'view' | 'edit' | 'delete' | undefined + * + * // Router info is also typed + * if (context.router) { + * const routerParams = context.router.params; // UserParams + * const handlers = context.router.handlers; // ContextHandler[] + * } + * }); + * + * // Router with middleware that uses typed context + * const apiRouter = new Router(null, { + * middlewares: [ + * async (context) => { + * console.log(`User ${context.params?.id} performing ${context.params?.action}`); + * console.log(`App: ${context.app?.constructor.name}`); + * } + * ] + * }); + * ``` + */ +export class Router = AppContext> { + /** Route prefix */ prefix: string; + /** Default HTTP method */ method: HttpMethod; - routers: Router[]; + /** Sub-routers */ + routers: Router[]; + /** Route handlers */ handlers: ContextHandler[]; + /** Middleware functions */ middlewares: ContextHandler[]; + /** Route validators */ validators: RouterValidator; + /** After middleware functions */ afters?: ContextHandler[]; constructor(prefix?: string, options?: RouterOptions); - add(...router: Router[]): this; - add(prefix: string, ...router: Router[]): this; + /** + * Add sub-routers to this router + */ + add>(...router: Router[]): this; + add>( + prefix: string, + ...router: Router[] + ): this; + /** + * Create a new sub-router + */ new(prefix: string, options?: RouterOptions): this; + /** + * Add a route with specific HTTP method + */ push( method: HttpMethod, prefix: string, @@ -237,36 +668,54 @@ export class Router { validator?: RouterValidator ): this; + /** + * Add a GET route + */ get( prefix: string, handle: ContextHandler, validator?: RouterValidator ): this; + /** + * Add a POST route + */ post( prefix: string, handle: ContextHandler, validator?: RouterValidator ): this; + /** + * Add a PUT route + */ put( prefix: string, handle: ContextHandler, validator?: RouterValidator ): this; + /** + * Add a PATCH route + */ patch( prefix: string, handle: ContextHandler, validator?: RouterValidator ): this; + /** + * Add a DELETE route + */ delete( prefix: string, handle: ContextHandler, validator?: RouterValidator ): this; + /** + * Add a route that accepts any HTTP method + */ any( prefix: string, handle: ContextHandler, @@ -274,87 +723,539 @@ export class Router { ): this; } +// ======================================== +// SSE Middleware Types +// ======================================== + +/** + * Options for Server-Sent Events middleware + */ type SSEOptions = { - pingInterval?: number; // default is 60000 - closeEvent?: string; // default is 'close' + /** Ping interval in milliseconds (default: 60000) */ + pingInterval?: number; + /** Event name for close event (default: 'close') */ + closeEvent?: string; }; -interface AppConfiguration { - [key: string]: any; - debug?: boolean; - app_id?: string; - routers?: Router[]; -} - +/** + * SSE context handler function type + */ type SSEContextHandler = ( context: Koa.ParameterizedContext, next: () => Promise ) => Promise; +/** + * Middleware namespace containing utility middleware functions + */ export namespace middlewares { + /** + * Create Server-Sent Events middleware + * @param options SSE configuration options + * @returns SSE middleware function + */ function KoaSSEMiddleware(options?: SSEOptions): SSEContextHandler; } +// ======================================== +// Application Configuration Types +// ======================================== + +/** + * Base application configuration interface + * + * @example + * ```typescript + * // Basic usage - supports mixed router types + * const config: AppConfiguration = { + * debug: true, + * app_id: 'my-app', + * routers: [ + * userRouter, // Router> + * productRouter, // Router> + * apiRouter // Router> + * ] + * }; + * + * // Type-safe usage with helper type + * const typedConfig: TypedAppConfiguration< + * Router>[] + * > = { + * debug: true, + * routers: [userRouter] // All routers must be of the same type + * }; + * ``` + */ +interface AppConfiguration { + [key: string]: any; + /** Enable debug mode */ + debug?: boolean; + /** Application identifier */ + app_id?: string; + /** Application routers - supports mixed router types for flexibility */ + routers?: Router[]; +} + +/** + * Typed application configuration for strict type checking when needed + * @template TRouters Array type of routers for strict typing + * + * @example + * ```typescript + * interface UserParams { id: string; } + * interface UserBody { name: string; } + * interface UserQuery { format?: 'json' | 'xml'; } + * + * type UserRouter = Router>; + * + * // Strict typing when all routers are of the same type + * const config: TypedAppConfiguration = { + * debug: true, + * routers: [ + * userRouter1, // Must be UserRouter + * userRouter2 // Must be UserRouter + * ] + * }; + * + * // Or for multiple specific types + * const mixedConfig: TypedAppConfiguration<(UserRouter | ProductRouter)[]> = { + * routers: [userRouter, productRouter] + * }; + * ``` + */ +interface TypedAppConfiguration[] = Router[]> + extends Omit { + /** Strictly typed application routers */ + routers?: TRouters; +} + +/** + * Koa application specific configuration + * + * @example + * ```typescript + * // Basic usage with mixed router types + * const config: KoaApplicationConfig = { + * listen_host: 'localhost', + * port: 3000, + * debug: true, + * routers: [ + * userRouter, // Different context types + * productRouter, // are supported + * apiRouter + * ] + * }; + * + * // Type-safe usage for specific router types + * interface UserParams { id: string; } + * interface UserBody { name: string; } + * type UserRouter = Router>; + * + * const typedConfig: TypedKoaApplicationConfig = { + * listen_host: 'localhost', + * port: 3000, + * routers: [userRouter1, userRouter2] // All must be UserRouter + * }; + * ``` + */ export type KoaApplicationConfig = AppConfiguration & { + /** Host to listen on */ listen_host: string; + /** Number of server instances */ count?: number; + /** Port to listen on */ port?: number; + /** Path mappings */ paths?: Record; + /** Koa server configuration */ server?: { + /** Environment mode */ env?: string | undefined; + /** Signing keys for cookies */ keys?: string[] | undefined; + /** Trust proxy headers */ proxy?: boolean | undefined; + /** Subdomain offset */ subdomainOffset?: number | undefined; + /** Proxy IP header */ proxyIpHeader?: string | undefined; + /** Maximum IPs count */ maxIpsCount?: number | undefined; }; + /** Session key name */ session_key?: string; + /** Session configuration */ session?: Partial; + /** Static file serving options */ static?: KoaStaticServer.Options; }; +/** + * Typed Koa application configuration for strict type checking + * @template TRouters Array type of routers for strict typing + */ +export type TypedKoaApplicationConfig< + TRouters extends Router[] = Router[] +> = TypedAppConfiguration & + Omit; + +/** + * Socket application configuration + * + * @example + * ```typescript + * // Basic usage with mixed router types + * const config: SocketAppConfiguration = { + * port: 8080, + * debug: true, + * routers: [ + * chatRouter, // Different context types + * gameRouter, // are supported + * notifyRouter + * ], + * ping: { open: true, interval: 30000 } + * }; + * + * // Type-safe usage for specific router types + * interface ChatParams { room: string; userId: string; } + * interface ChatBody { message: string; type: 'text' | 'image'; } + * type ChatRouter = Router>; + * + * const typedConfig: TypedSocketAppConfiguration = { + * port: 8080, + * routers: [chatRouter1, chatRouter2] // All must be ChatRouter + * }; + * ``` + */ +export type SocketAppConfiguration = AppConfiguration & { + /** Port to listen on */ + port: number; + /** Ping configuration */ + ping?: { + /** Enable ping */ + open?: boolean; + /** Ping interval in milliseconds */ + interval?: number; + /** Ping data */ + data?: any; + }; +}; + +/** + * Typed Socket application configuration for strict type checking + * @template TRouters Array type of routers for strict typing + */ +export type TypedSocketAppConfiguration< + TRouters extends Router[] = Router[] +> = TypedAppConfiguration & + Omit; + +/** + * WebSocket application configuration + * + * @example + * ```typescript + * // Basic usage with mixed router types + * const config: WebSocketAppConfiguration = { + * port: 8080, + * debug: true, + * routers: [ + * wsRouter1, // Different context types + * wsRouter2, // are supported + * wsRouter3 + * ], + * // WebSocket server options + * clientTracking: true, + * maxPayload: 1024 * 1024 + * }; + * + * // Type-safe usage for specific router types + * interface WSParams { channel: string; userId: string; } + * interface WSBody { event: string; data: any; } + * type WSRouter = Router>; + * + * const typedConfig: TypedWebSocketAppConfiguration = { + * port: 8080, + * routers: [wsRouter1, wsRouter2] // All must be WSRouter + * }; + * ``` + */ +export type WebSocketAppConfiguration = ServerOptions & SocketAppConfiguration; + +/** + * Typed WebSocket application configuration for strict type checking + * @template TRouters Array type of routers for strict typing + */ +export type TypedWebSocketAppConfiguration< + TRouters extends Router[] = Router[] +> = ServerOptions & TypedSocketAppConfiguration; + +// ======================================== +// Application Classes +// ======================================== + +/** + * Trigger function type for events + */ type TriggerFunc = (...args: any[]) => void; +/** + * Base application class extending EventEmitter + */ export declare abstract class Application extends EventEmitter { + /** Application routes */ routes: any; + /** Application identifier */ app_id: string; + /** Application configuration */ config: Configuration; + constructor(config: AppConfiguration); + + /** + * Start the application + * @returns Promise that resolves when application is started + */ abstract start(): Promise; } +/** + * Koa-based HTTP application + */ export declare class KoaApplication extends Application { + /** Koa instance */ koa: Koa; + /** Workflow instance for request processing */ workflow: Workflow; + constructor(config: KoaApplicationConfig); + + /** + * Start the Koa application server + * @returns Promise that resolves when server is started + */ start(): Promise; } +/** + * Socket context extending AppContext + * @template TParams Type of route parameters (defaults to Record) + * @template TBody Type of request body (defaults to any) + * @template TQuery Type of query parameters (defaults to any) + * + * @example + * ```typescript + * // Define socket-specific types + * interface SocketParams { room: string; userId: string; } + * interface SocketBody { message: string; type: 'text' | 'image'; } + * interface SocketQuery { token?: string; } + * + * type ChatContext = SocketContext; + * + * // Use in socket handler + * const socketHandler = async (context: ChatContext) => { + * // All properties are fully typed + * const room = context.params.room; // string + * const userId = context.params.userId; // string + * const message = context.body.message; // string + * const msgType = context.body.type; // 'text' | 'image' + * const token = context.query.token; // string | undefined + * + * // Router info is also typed + * const routerParams = context.router?.params; // SocketParams + * }; + * ``` + */ +export interface SocketContext< + TParams = Record, + TBody = any, + TQuery = any +> extends AppContext { + /** Route parameters */ + params?: TParams; + /** Application configuration */ + config?: AppConfiguration; + /** Socket connection */ + socket: Socket; + /** Request body */ + body?: TBody; + /** Query parameters */ + query?: TQuery; + /** Request headers */ + headers?: IncomingHttpHeaders; + /** Response object */ + response?: HttpResponse | HttpError; +} + +/** + * Socket client wrapper + */ +export declare class SocketClient { + /** Socket connection options */ + options: { + /** Port number */ + port: number; + /** Host address */ + host: string; + /** Client name */ + name?: string; + }; + /** Event emitter for socket events */ + event: EventEmitter; + /** Socket client instance */ + client: Socket; + + constructor(socket: Socket, app_id: string); + + /** + * Send data to socket client + * @param data Data to send + */ + send(data: any): void; + + /** + * Close socket connection + */ + close(): void; +} + +/** + * Socket-based application + */ +export declare class SocketApplication extends Application { + constructor(config: SocketAppConfiguration); + + /** + * Start the socket application server + * @returns Promise that resolves when server is started + */ + start(): Promise; + + /** + * Broadcast data to all connected clients + * @param data Data to broadcast + * @param msg Message + * @param code Status code + * @param connections Specific connections to broadcast to + */ + broadcast( + data?: any, + msg?: string, + code?: number, + connections?: Socket[] + ): void; +} + +/** + * WebSocket-based application + */ +export declare class WebSocketApplication extends Application { + constructor(config: WebSocketAppConfiguration); + + /** + * Start the WebSocket application server + * @returns Promise that resolves when server is started + */ + start(): Promise; + + /** + * Broadcast data to all connected WebSocket clients + * @param data Data to broadcast + * @param msg Message + * @param code Status code + * @param connections Specific connections to broadcast to + */ + broadcast( + data?: any, + msg?: string, + code?: number, + connections?: WebSocket[] + ): void; +} + +// ======================================== +// Model Class +// ======================================== + +/** + * Base model class for data validation and manipulation + */ export declare class Model { constructor(obj?: { [key: string]: any }, rules?: Rules, msg?: ErrorMessages); + /** + * Create a new model instance + * @param obj Initial data object + * @param rules Validation rules + * @param msg Custom error messages + * @returns New model instance + */ static create( obj?: { [key: string]: any }, rules?: Rules, msg?: ErrorMessages ): T; + /** + * Convert model to JSON string + * @returns JSON string representation + */ toJson(): string; + /** + * Get all property names + * @returns Array of property names + */ properties(): Array; + /** + * Get property count + * @returns Number of properties + */ count(): number; + /** + * Validate model data + * @param rules Validation rules + * @param msg Custom error messages + * @returns Validator instance + */ validate(rules: Rules, msg?: ErrorMessages): Validator; } +// ======================================== +// Utility Functions +// ======================================== + +/** + * Initialize application context + * @template T Application type + * @template TParams Type of route parameters + * @template TBody Type of request body + * @template TQuery Type of query parameters + * @template F Context type extending AppContext + * @param options Context initialization options + * @returns Initialized context + */ export function initContext< T extends Application, - F extends AppContext + TParams = Record, + TBody = any, + TQuery = any, + F extends AppContext = AppContext< + TParams, + TBody, + TQuery + > >(options: { + /** Application instance */ app: T; + /** Application routes */ routes: Router[]; + /** HTTP method */ method?: string; + /** Request path */ pathinfo?: string; + /** Application ID */ app_id?: string; }): F & { app: T }; diff --git a/index.js b/index.js index 86cca8f..ce464bf 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,5 @@ 'use strict'; -const { Application, KoaApplication } = require('./src/apps'); const Controller = require('./src/controller'); const { Router } = require('./src/router'); const response = require('./src/response'); @@ -9,11 +8,20 @@ const { KoaSSEMiddleware } = require('./src/middlewares/sse'); const { initContext } = require('./src/core'); const multer = require('@koa/multer'); const session = require('koa-session'); +const { SocketClient } = require('./src/utils'); +const { + Application, + KoaApplication, + SocketApplication, + WebSocketApplication +} = require('./src/apps'); module.exports = { Controller, Application, KoaApplication, + SocketApplication, + WebSocketApplication, Model, Router, @@ -26,5 +34,7 @@ module.exports = { // functions ...response, - initContext + initContext, + + SocketClient }; diff --git a/package.json b/package.json index 7a5ea5f..9312177 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "koa-static-server": "^1.5.2", "multer": "^1.4.5-lts.1", "uuid": "^9.0.1", - "validatorjs": "^3.22.1" + "validatorjs": "^3.22.1", + "ws": "^8.18.2" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/apps/index.js b/src/apps/index.js index 7b8687a..c406b7f 100644 --- a/src/apps/index.js +++ b/src/apps/index.js @@ -2,10 +2,12 @@ const Application = require('./app'); const KoaApplication = require('./koa'); -const SocketApplication = require('./koa'); +const SocketApplication = require('./socket'); +const WebSocketApplication = require('./websocket'); module.exports = { Application, KoaApplication, - SocketApplication + SocketApplication, + WebSocketApplication }; diff --git a/src/apps/socket.js b/src/apps/socket.js new file mode 100644 index 0000000..b329d9b --- /dev/null +++ b/src/apps/socket.js @@ -0,0 +1,162 @@ +'use strict'; + +const net = require('net'); +const EventEmitter = require('events'); +const Application = require('./app'); +const { debug, printer, Workflow } = require('@axiosleo/cli-tool'); +const { _uuid_salt } = require('../utils'); +const { initContext } = require('../core'); +const is = require('@axiosleo/cli-tool/src/helper/is'); +const operator = require('../workflows/socket.workflow'); +const { _assign } = require('@axiosleo/cli-tool/src/helper/obj'); +const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); + +const dispatcher = ({ app, app_id, workflow, connection }) => { + return async (ctx) => { + let context = initContext({ + app, + connection, + method: ctx.method ? ctx.method.toUpperCase() : 'GET', + pathinfo: ctx.path, + app_id, + }); + context.socket = connection; + context.query = ctx.query || {}; + context.body = ctx.body || {}; + try { + await workflow.start(context); + } catch (exContext) { + context = exContext; + } + }; +}; + +/** + * @param {import('../../').SocketContext} context + */ +const handleRes = (context) => { + let response = context.response; + let data = ''; + if (response.format === 'json' && response.notResolve !== true) { + let code, message; + if (response.code) { + [code, message] = response.code.split(';'); + } + data = JSON.stringify({ + request_id: context.request_id, + timestamp: (new Date()).getTime(), + code: code || `${response.status}`, + message: message || context.response.message, + data: response.data + }); + } else { + data = response.data; + } + context.socket.write(data + '@@@@@@'); +}; + +async function ping(data, interval) { + this.broadcast(data, 'ping', 0); + await _sleep(interval); + process.nextTick(() => { + ping.call(this, data, interval); + }); +} + +class SocketApplication extends Application { + constructor(options) { + super(options); + + this.event = new EventEmitter(); + this.port = this.config.port || 8081; + this.connections = {}; + this.on('response', handleRes); + this.workflow = new Workflow(operator); + this.ping = {}; + _assign(this.ping, { + open: false, + interval: 1000 * 60 * 5, + data: 'this is a ping message' + }, this.config.ping || {}); + } + + async start() { + const server = net.createServer((connection) => { + try { + let connection_id = _uuid_salt('connect:' + this.app_id); + this.connections[connection_id] = connection; + debug.log('[Socket App]', 'Current connections:', Object.keys(this.connections).length); + this.event.emit('connection', connection); + connection.pipe(connection); + const self = this; + connection.on('data', function (data) { + try { + /** + * @example '{"path":"/test","method":"GET","query":{"test":123}}@@@@@@' + */ + let msg = Buffer.from(data.subarray(0, data.length - 6)).toString(); + const context = JSON.parse(msg); + const callback = dispatcher({ + app: self, + app_id: self.app_id, + workflow: self.workflow, + connection + }); + process.nextTick(callback, context); + } catch (err) { + debug.log('[Socket App]', err.message); + } + }); + connection.on('end', () => { + delete this.connections[connection_id]; + debug.log('[Socket App]', 'Current connections:', Object.keys(this.connections).length); + }); + } catch (err) { + debug.log('[Socket App]', 'create socket server failed.', err); + } + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + debug.error('[Socket App]', 'The listening port is in use.', this.port); + } else { + debug.error('[Socket App]', 'socket server error:', err); + } + }); + if (this.ping.open) { + const self = this; + printer.info('[Socket App] ping is open.'); + process.nextTick(() => { + ping.call(self, self.ping.data, self.ping.interval); + }); + } + + server.listen(this.port, () => { + printer.info(`Server is running on port ${this.port}`); + this.event.emit('listen', this.port); + }); + } + + broadcast(data = '', msg = 'ok', code = 0, connections = []) { + data = JSON.stringify({ + request_id: _uuid_salt(this.app_id), + timestamp: (new Date()).getTime(), + code, + message: msg, + data: data + }); + data = `${data}@@@@@@`; + if (connections === null) { + if (is.empty(this.connections)) { + return; + } + Object.keys(this.connections).map((id) => this.connections[id].write(data)); + } else if (is.array(connections)) { + connections.map((conn) => conn.write(data)); + } else if (is.object(connections)) { + Object.keys(connections).map((id) => connections[id].write(data)); + } + } +} + +module.exports = SocketApplication; diff --git a/src/apps/websocket.js b/src/apps/websocket.js new file mode 100644 index 0000000..4816f5c --- /dev/null +++ b/src/apps/websocket.js @@ -0,0 +1,173 @@ +'use strict'; + +const { WebSocketServer } = require('ws'); +const EventEmitter = require('events'); +const Application = require('./app'); +const { debug, printer, Workflow } = require('@axiosleo/cli-tool'); +const { _uuid_salt } = require('../utils'); +const { initContext } = require('../core'); +const is = require('@axiosleo/cli-tool/src/helper/is'); +const operator = require('../workflows/socket.workflow'); +const { _assign } = require('@axiosleo/cli-tool/src/helper/obj'); +const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); + +/** + * + * @param {{request: import('http').IncomingMessage}} param0 + * @returns + */ +const dispatcher = ({ app, app_id, workflow, connection, request }) => { + return async (ctx) => { + const url = new URL(request.url, `ws://localhost:${app.port}`); + let context = initContext({ + app, + method: request.method ? request.method.toUpperCase() : 'GET', + pathinfo: url.pathname, + app_id, + }); + context.socket = connection; + context.query = connection.connectionQuery || {}; + context.body = ctx || {}; + context.headers = request.headers; + try { + await workflow.start(context); + } catch (exContext) { + context = exContext; + } + }; +}; + +/** + * @param {import('../../').SocketContext} context + */ +const handleRes = (context) => { + let response = context.response; + let data = ''; + if (response.format === 'json' && response.notResolve !== true) { + let code, message; + if (response.code) { + [code, message] = response.code.split(';'); + } + data = JSON.stringify({ + request_id: context.request_id, + timestamp: (new Date()).getTime(), + code: code || `${response.status}`, + message: message || context.response.message, + data: response.data + }); + } else { + data = response.data; + } + context.socket.send(data); +}; + +async function ping(data, interval) { + this.broadcast(data, 'ping', 0); + await _sleep(interval); + process.nextTick(() => { + ping.call(this, data, interval); + }); +} + +class WebSocketApplication extends Application { + /** + * + * @param {import('../../').WebSocketAppConfiguration} options + */ + constructor(options) { + super(options); + + this.event = new EventEmitter(); + this.port = this.config.port || 8081; + this.connections = {}; + this.on('response', handleRes); + this.workflow = new Workflow(operator); + this.ping = {}; + _assign(this.ping, { + open: false, + interval: 1000 * 60 * 5, + data: 'this is a ping message' + }, this.config.ping || {}); + delete options.ping; + this.websocketOptions = options; + } + + async start() { + const wss = new WebSocketServer(this.websocketOptions); + printer.info(`Server is running on port ${this.port}`); + this.event.emit('listen', this.port); + const self = this; + wss.on('connection', (ws, request) => { + let connection_id = _uuid_salt('connect:' + this.app_id); + this.connections[connection_id] = ws; + + debug.log('[WebSocket App]', 'Current connections:', Object.keys(this.connections).length); + this.event.emit('connection', ws, request); + + ws.on('message', (data) => { + try { + /** + * @example '{"path":"/test","method":"GET","query":{"test":123}}' + */ + let msg = Buffer.from(data).toString(); + const context = JSON.parse(msg); + const callback = dispatcher({ + app: self, + app_id: self.app_id, + workflow: self.workflow, + connection: ws, + request + }); + process.nextTick(callback, context); + } catch (err) { + debug.log('[Socket App]', err.message); + } + }); + ws.on('error', (err) => { + debug.error('[Socket App]', 'socket server error:', err); + }); + ws.on('close', () => { + delete this.connections[connection_id]; + debug.log('[Socket App]', 'Current connections:', Object.keys(this.connections).length); + }); + }); + + wss.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + debug.error('[Socket App]', 'The listening port is in use.', this.port); + } else { + debug.error('[Socket App]', 'socket server error:', err); + } + }); + + if (this.ping.open) { + const self = this; + printer.info('[Socket App] ping is open.'); + process.nextTick(() => { + ping.call(self, self.ping.data, self.ping.interval); + }); + } + } + + broadcast(data = '', msg = 'ok', code = 0, connections = []) { + data = JSON.stringify({ + request_id: _uuid_salt(this.app_id), + timestamp: (new Date()).getTime(), + code, + message: msg, + data: data + }); + if (connections === null) { + if (is.empty(this.connections)) { + return; + } + Object.keys(this.connections).map((id) => this.connections[id].send(data)); + } else if (is.array(connections)) { + connections.map((conn) => conn.send(data)); + } else if (is.object(connections)) { + Object.keys(connections).map((id) => connections[id].send(data)); + } + } +} + +module.exports = WebSocketApplication; diff --git a/src/core.js b/src/core.js index 2116c7f..9e9f629 100644 --- a/src/core.js +++ b/src/core.js @@ -1,7 +1,7 @@ 'use strict'; -const { v4, v5, validate } = require('uuid'); const is = require('@axiosleo/cli-tool/src/helper/is'); +const { _uuid_salt } = require('./utils'); const resolvePathinfo = (pathinfo) => { let trace = []; @@ -200,7 +200,7 @@ const initContext = (options = {}) => { step_data: {}, method: method, pathinfo, - request_id: `${v5(v4(), !validate(app_id) ? v4() : app_id)}`, + request_id: _uuid_salt(app_id), }; return context; }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..44f0dbe --- /dev/null +++ b/src/utils.js @@ -0,0 +1,172 @@ +'use strict'; + +const net = require('net'); +const { printer, debug } = require('@axiosleo/cli-tool'); +const is = require('@axiosleo/cli-tool/src/helper/is'); +const { _fixed, _str } = require('@axiosleo/cli-tool/src/helper/str'); +const EventEmitter = require('events'); +const { v4, v5, validate } = require('uuid'); + +function _uuid() { + return v4(); +} + +function _uuid_salt(salt = '') { + return `${v5(v4(), !validate(salt) ? v4() : salt)}`; +} + +/** + * @param {import("..").KoaContext} context + */ +function _debug(context, location, error) { + const wide = 12; + printer.println('-'.repeat(30) + '[DEBUG Info]' + '-'.repeat(30)); + printer.yellow(_fixed('requestID', wide)).print(': ').println(context.request_id); + if (!error) { + printer.yellow('responseData').print(': '); + // eslint-disable-next-line no-console + console.log(context.response.data); + } + if (location && location.indexOf('node:internal') === -1) { + printer.print('response '.data).print(': ').print(location.trim().yellow).println(); + } + printer.yellow(_fixed('datetime', wide)).print(': ').println(new Date().toLocaleString()); + printer.yellow(_fixed('method', wide)).print(': ').green(context.method).println(); + printer.yellow(_fixed('path', wide)).print(': ').println(context.url); + if (!context.router) { + return; + } + const router = context.router; + ['pathinfo', 'validators'].forEach(k => { + if (is.empty(router[k])) { + return; + } + printer.yellow(_fixed(k, wide)).print(': ').println(typeof router[k] === 'object' ? JSON.stringify(router[k]) : _str(router[k])); + }); + ['query', 'params', 'body'].forEach(k => { + if (is.empty(context[k])) { + return; + } + printer.yellow(_fixed(k, wide)).print(': ').println(typeof context[k] === 'object' ? JSON.stringify(context[k]) : _str(context[k])); + }); +} + +class SocketClient { + constructor(options = {}) { + this.options = Object.assign({ + port: 8081, + host: 'localhost', + name: 'default', + }, options); + this.event = new EventEmitter(); + this.client = net.connect(this.options); + this.client.on('connect', () => { + debug.log('connection success'); + this.event.emit('connect'); + }); + this.cache = []; + this.client.on('data', (data) => { + let str = data.toString(); + if (str.indexOf('@@@@@@') === -1) { + debug.log('no end tag, push to cache', str); + this.cache.push(data); + return; + } + this.cache.push(data.subarray(0, data.indexOf('@@@@@@'))); + str = Buffer.concat(this.cache).toString(); + let temp = data.subarray(data.indexOf('@@@@@@') + 6); + this.cache = temp.length > 0 ? [temp] : []; + if (str.indexOf('@@@@@@') !== -1) { + str = str.substring(0, str.indexOf('@@@@@@')); + } + if (str.length === 0) { + return this.event.emit('error', new Error('empty data')); + } + this.event.emit('data', JSON.parse(str)); + }); + this.client.on('error', (err) => { + debug.log('connection error', err.message); + // this.event.emit('error', err); + }); + const self = this; + this.client.on('end', function () { + debug.log('connection end, will reconnect', self.options); + }); + } + + reconnect() { + if (this.client) { + this.client.destroy(); + } + this.client = net.connect(this.options); + this.client.on('connect', () => { + debug.log('connection success'); + this.event.emit('connect'); + }); + this.client.on('data', (data) => { + let str = data.subarray(0, data.length - 6).toString(); + this.event.emit('data', JSON.parse(str)); + }); + this.client.on('error', (err) => { + debug.log('connection error', err.message); + this.event.emit('error', err); + }); + this.client.on('end', function () { + debug.log('connection end'); + }); + this.event.emit('reconnect'); + } + + async send(method, pathinfo, query = {}, body = {}) { + const methods = ['get', 'post', 'put', 'delete', 'patch']; + if (!is.string(method) || !methods.includes(method.toLowerCase())) { + throw new Error('method must be one of get, post, put, delete, patch'); + } + if (!is.string(pathinfo)) { + throw new Error('pathinfo must be a string'); + } + if (!is.object(query)) { + throw new Error('query must be an object'); + } + if (!is.object(body)) { + throw new Error('body must be an object'); + } + if (this.client && this.client.destroyed) { + this.reconnect(); + } + if (!this.client) { + throw new Error('socket client is not connected'); + } + const self = this; + return new Promise((resolve, reject) => { + if (this.client) { + const bufferBody = Buffer.from(`${Buffer.from(JSON.stringify({ + path: pathinfo, + method, + query, + body + }).toString('base64'))}@@@@@@`); + this.event.on('data', (data) => { + debug.log('data123', { data }); + resolve(data); + }); + this.client.write(bufferBody, (e) => { + if (e) { + self.event.emit('error', e); + reject(e); + } + }); + } else { + reject(new Error('socket client is not connected')); + } + }); + } +} + +module.exports = { + _uuid, + _debug, + _uuid_salt, + + SocketClient +}; diff --git a/src/workflows/koa.workflow.js b/src/workflows/koa.workflow.js index 05ebbdb..fe96642 100644 --- a/src/workflows/koa.workflow.js +++ b/src/workflows/koa.workflow.js @@ -6,7 +6,7 @@ const is = require('@axiosleo/cli-tool/src/helper/is'); const { failed, error, HttpResponse, HttpError } = require('../response'); const { _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); const { getRouteInfo } = require('../core'); -const { _str, _fixed } = require('@axiosleo/cli-tool/src/helper/str'); +const { _debug } = require('../utils'); /** * receive request @@ -115,43 +115,6 @@ async function handle(context) { } } -/** - * - * @param {import("..").KoaContext} context - */ -function showDebugInfo(context, location, error) { - const wide = 12; - printer.println('-'.repeat(30) + '[DEBUG Info]' + '-'.repeat(30)); - printer.yellow(_fixed('requestID', wide)).print(': ').println(context.request_id); - if (!error) { - printer.yellow('responseData').print(': '); - // eslint-disable-next-line no-console - console.log(context.response.data); - } - if (location && location.indexOf('node:internal') === -1) { - printer.print('response '.data).print(': ').print(location.trim().yellow).println(); - } - printer.yellow(_fixed('datetime', wide)).print(': ').println(new Date().toLocaleString()); - printer.yellow(_fixed('method', wide)).print(': ').green(context.method).println(); - printer.yellow(_fixed('path', wide)).print(': ').println(context.url); - if (!context.router) { - return; - } - const router = context.router; - ['pathinfo', 'validators'].forEach(k => { - if (is.empty(router[k])) { - return; - } - printer.yellow(_fixed(k, wide)).print(': ').println(typeof router[k] === 'object' ? JSON.stringify(router[k]) : _str(router[k])); - }); - ['query', 'params', 'body'].forEach(k => { - if (is.empty(context[k])) { - return; - } - printer.yellow(_fixed(k, wide)).print(': ').println(typeof context[k] === 'object' ? JSON.stringify(context[k]) : _str(context[k])); - }); -} - /** * set response * @param {import("..").KoaContext} context @@ -198,7 +161,7 @@ function response(context) { } context.response = response; if (error) { - showDebugInfo(context, '', error); + _debug(context, '', error); printer.red('requestError').print(': '); // eslint-disable-next-line no-console console.log(error); @@ -206,7 +169,7 @@ function response(context) { if (context.app.config.debug && !error) { let tmp = context.response.stack.split(os.EOL); let t = tmp.find((s) => !s.startsWith('Error:') && s.indexOf('node_modules') === -1); - showDebugInfo(context, t); + _debug(context, t); } context.app.emit('response', context); } diff --git a/src/workflows/socket.workflow.js b/src/workflows/socket.workflow.js new file mode 100644 index 0000000..d864230 --- /dev/null +++ b/src/workflows/socket.workflow.js @@ -0,0 +1,200 @@ +'use strict'; + +const os = require('os'); +const { printer } = require('@axiosleo/cli-tool'); +const { getRouteInfo } = require('../core'); +const { error, failed, HttpResponse, HttpError } = require('../response'); +const is = require('@axiosleo/cli-tool/src/helper/is'); +const Validator = require('validatorjs'); +const { _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { _debug } = require('../utils'); + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +function receive(context) { + try { + context.app.emit('receive', context); + const router = getRouteInfo(context.app.routes, context.pathinfo, context.method); + if (!router) { + error(404, 'Not Found'); + } + context.params = router && router.params ? router.params : {}; + context.router = router; + } catch (err) { + context.response = err; + return 'response'; + } +} + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +function validate(context) { + try { + context.app.emit('validate', context); + if (context.router && context.router.validators) { + const { params, query, body } = context.router.validators; + const check = {}; + if (!is.empty(params)) { + const validation = new Validator(context.params, params.rules, params.messages || null); + validation.check(); + if (validation.fails()) { + const errors = validation.errors.all(); + check.params = errors; + } + } + if (!is.empty(query)) { + const validation = new Validator(context.query, query.rules, query.messages || null); + validation.check(); + if (validation.fails()) { + const errors = validation.errors.all(); + check.query = errors; + } + } + if (!is.empty(body)) { + const validation = new Validator(context.body, body.rules, body.messages || null); + validation.check(); + if (validation.fails()) { + const errors = validation.errors.all(); + check.body = errors; + } + } + if (!is.empty(check)) { + failed(check, '400;Bad Request Data', 400); + } + } + } catch (err) { + context.response = err; + return 'response'; + } +} + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +async function middleware(context) { + try { + context.app.emit('middleware', context); + // exec middleware by routes configuration + if (context.router && context.router.middlewares && context.router.middlewares.length > 0) { + await _foreach(context.router.middlewares, async (middleware) => { + await middleware(context); + }); + } + } catch (err) { + context.response = err; + return 'response'; + } +} + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +async function handle(context) { + try { + context.app.emit('handle', context); + if (context.router && context.router.handlers + && context.router.handlers.length > 0) { + await _foreach(context.router.handlers, async (handler) => { + await handler(context); + }); + } + } catch (err) { + context.response = err; + return; + } +} + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +function response(context) { + if (!context.response) { + return; + } + if (!context.response && context.curr && context.curr.error) { + context.response = context.curr.error || new Error('unknown error'); + } + let response; + let error; + if (context.response instanceof HttpResponse) { + response = context.response; + } else if (context.response instanceof HttpError) { + response = new HttpResponse({ + format: 'json', + status: context.response.status, + message: context.response.message, + data: {} + }); + } else if (context.app.config.debug) { + error = context.response; + response = new HttpResponse({ + format: 'json', + status: 500, + data: { + code: 500, + message: 'Internal Server Error', + data: { + code: context.response.code, + msg: context.response.message, + stack: context.response.stack, + }, + } + }); + } else { + error = context.response; + response = new HttpResponse({ + status: 500, + data: 'Internal Server Error' + }); + } + context.response = response; + if (error) { + _debug(context, '', error); + printer.red('requestError').print(': '); + // eslint-disable-next-line no-console + console.log(error); + } + if (context.app.config.debug && !error) { + let tmp = context.response.stack.split(os.EOL); + let t = tmp.find((s) => !s.startsWith('Error:') && s.indexOf('node_modules') === -1); + _debug(context, t); + } + context.app.emit('response', context); +} + +/** + * @param {import('../../index').SocketContext} context + * @returns + */ +async function after(context) { + try { + context.app.emit('request_end', context); + if (context.router && context.router.afters && context.router.afters.length > 0) { + await _foreach(context.router.afters, async (after) => { + try { + await after(context); + } catch (err) { + context.app.emit('after_error', context, err); + } + }); + } + } catch (err) { + context.response = err; + } +} + +module.exports = { + receive, + validate, + middleware, + handle, + response, + after +};