diff --git a/packages/core/package.json b/packages/core/package.json index c57d33260..cd9c60bd5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,10 +43,13 @@ "detect-ts-node": "^1.0.5", "glob": "^7.2.0", "multer": "1.4.5-lts.1", + "path-parser": "^6.1.0", "raw-body": "^2.0.0", "reflect-metadata": ">=0.1.12", "rxjs": ">=6.0.3", - "typia": "^6.0.3" + "tgrid": "^0.10.3", + "typia": "^6.0.3", + "ws": "^7.5.3" }, "peerDependencies": { "@nestia/fetcher": ">=3.0.5", @@ -65,6 +68,7 @@ "@types/inquirer": "^9.0.3", "@types/multer": "^1.4.11", "@types/ts-expose-internals": "npm:ts-expose-internals@5.2.2", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.46.1", "@typescript-eslint/parser": "^5.46.1", "commander": "^10.0.0", diff --git a/packages/core/src/adaptors/WebSocketAdaptor.ts b/packages/core/src/adaptors/WebSocketAdaptor.ts new file mode 100644 index 000000000..66b82eb4c --- /dev/null +++ b/packages/core/src/adaptors/WebSocketAdaptor.ts @@ -0,0 +1,419 @@ +/// +import { INestApplication, VersioningType } from "@nestjs/common"; +import { + HOST_METADATA, + MODULE_PATH, + PATH_METADATA, + SCOPE_OPTIONS_METADATA, + VERSION_METADATA, +} from "@nestjs/common/constants"; +import { VERSION_NEUTRAL, VersionValue } from "@nestjs/common/interfaces"; +import { NestContainer } from "@nestjs/core"; +import { InstanceWrapper } from "@nestjs/core/injector/instance-wrapper"; +import { Module } from "@nestjs/core/injector/module"; +import getFunctionLocation from "get-function-location"; +import { IncomingMessage, Server } from "http"; +import { Path } from "path-parser"; +import { Duplex } from "stream"; +import { WebAcceptor } from "tgrid"; +import typia from "typia"; +import WebSocket from "ws"; + +import { IWebSocketRouteReflect } from "../decorators/internal/IWebSocketRouteReflect"; +import { ArrayUtil } from "../utils/ArrayUtil"; + +export class WebSocketAdaptor { + public static async upgrade( + app: INestApplication, + ): Promise { + return new this(app, await visitApplication(app)); + } + + public readonly close = async (): Promise => + new Promise((resolve) => { + this.http.off("close", this.close); + this.http.off("upgrade", this.handleUpgrade); + this.ws.close(() => resolve()); + }); + + protected constructor(app: INestApplication, operations: IOperator[]) { + this.operators = operations; + this.ws = new WebSocket.Server({ noServer: true }); + this.http = app.getHttpServer(); + this.http.on("close", this.close); + this.http.on("upgrade", this.handleUpgrade); + } + + private readonly handleUpgrade = ( + request: IncomingMessage, + duplex: Duplex, + head: Buffer, + ) => { + this.ws.handleUpgrade(request, duplex, head, (client, request) => + WebAcceptor.upgrade( + request, + client as any, + async (acceptor): Promise => { + for (const op of this.operators) { + const params: Record | null = op.parser.test( + acceptor.path, + ); + if (params !== null) + try { + await op.handler({ params, acceptor }); + } catch (error) { + if ( + acceptor.state === WebAcceptor.State.OPEN || + acceptor.state === WebAcceptor.State.ACCEPTING + ) + await acceptor.reject( + 1008, + error instanceof Error + ? JSON.stringify({ ...error }) + : "unknown error", + ); + } finally { + return; + } + } + await acceptor.reject(1002, `Cannot GET ${acceptor.path}`); + }, + ), + ); + }; + + private readonly http: Server; + private readonly operators: IOperator[]; + private readonly ws: WebSocket.Server; +} + +const visitApplication = async ( + app: INestApplication, +): Promise => { + const operators: IOperator[] = []; + const errors: IControllerError[] = []; + + const config: IConfig = { + globalPrefix: + typeof (app as any).config?.globalPrefix === "string" + ? (app as any).config.globalPrefix + : undefined, + versioning: (() => { + const versioning = (app as any).config?.versioningOptions; + return versioning === undefined || versioning.type !== VersioningType.URI + ? undefined + : { + prefix: + versioning.prefix === undefined || versioning.prefix === false + ? "v" + : versioning.prefix, + defaultVersion: versioning.defaultVersion, + }; + })(), + }; + const container: NestContainer = (app as any).container as NestContainer; + const modules: Module[] = [...container.getModules().values()].filter( + (m) => !!m.controllers?.size, + ); + for (const m of modules) { + const modulePrefix: string = + Reflect.getMetadata( + MODULE_PATH + container.getModules().applicationId, + m.metatype, + ) ?? + Reflect.getMetadata(MODULE_PATH, m.metatype) ?? + ""; + for (const controller of m.controllers.values()) + visitController({ + config, + errors, + operators, + controller, + modulePrefix, + }); + } + if (errors.length) { + throw new Error( + [ + `WebSocketAdaptor: ${errors.length} error(s) found:`, + ``, + ...errors.map((e) => + [ + ` - controller: ${e.name}`, + ` - methods:`, + ...e.methods.map((m) => + [ + ` - name: ${m.name}`, + ` - file: ${m.source}:${m.line}:${m.column}`, + ` - reasons:`, + ...m.messages.map( + (msg) => + ` - ${msg + .split("\n") + .map((str) => ` ${str}`) + .join("\n")}`, + ), + ] + .map((str) => ` ${str}`) + .join("\n"), + ), + ] + .map((str) => ` ${str}`) + .join("\n"), + ), + ].join("\n"), + ); + } + return operators; +}; + +const visitController = async (props: { + config: IConfig; + errors: IControllerError[]; + operators: IOperator[]; + controller: InstanceWrapper; + modulePrefix: string; +}): Promise => { + if ( + ArrayUtil.has( + Reflect.getMetadataKeys(props.controller.metatype), + PATH_METADATA, + HOST_METADATA, + SCOPE_OPTIONS_METADATA, + ) === false + ) + return; + + const methodErrors: IMethodError[] = []; + const controller: IController = { + name: props.controller.name, + instance: props.controller.instance, + constructor: props.controller.metatype, + prototype: Object.getPrototypeOf(props.controller.instance), + prefixes: (() => { + const value: string | string[] = Reflect.getMetadata( + PATH_METADATA, + props.controller.metatype, + ); + if (typeof value === "string") return [value]; + else if (value.length === 0) return [""]; + else return value; + })(), + versions: getVersions( + Reflect.getMetadata(VERSION_METADATA, props.controller.metatype), + ), + modulePrefix: props.modulePrefix, + }; + for (const mk of Object.getOwnPropertyNames(controller.prototype).filter( + (key) => + key !== "constructor" && typeof controller.prototype[key] === "function", + )) { + const errorMessages: string[] = []; + visitMethod({ + config: props.config, + operators: props.operators, + controller, + method: { + key: mk, + value: controller.prototype[mk], + }, + report: (msg) => errorMessages.push(msg), + }); + if (errorMessages.length) + methodErrors.push({ + name: mk, + messages: errorMessages, + ...(await getFunctionLocation(controller.prototype[mk])), + }); + } + + if (methodErrors.length) + props.errors.push({ + name: controller.name, + methods: methodErrors, + }); +}; + +const visitMethod = (props: { + config: IConfig; + operators: IOperator[]; + controller: IController; + method: Entry; + report: (message: string) => void; +}): void => { + const route: IWebSocketRouteReflect | undefined = Reflect.getMetadata( + "nestia/WebSocketRoute", + props.method.value, + ); + if (typia.is(route) === false) return; + + const parameters: IWebSocketRouteReflect.IArgument[] = ( + (Reflect.getMetadata( + "nestia/WebSocketRoute/Parameters", + props.controller.prototype, + props.method.key, + ) ?? []) as IWebSocketRouteReflect.IArgument[] + ).sort((a, b) => a.index - b.index); + // acceptor must be + if (parameters.some((p) => p.category === "acceptor") === false) + return props.report( + "@WebSocketRoute.Acceptor() decorated parameter must be.", + ); + // length of parameters must be fulfilled + if (parameters.length !== props.method.value.length) + return props.report( + [ + "Every parameters must be one of below:", + " - @WebSocketRoute.Acceptor()", + " - @WebSocketRoute.Driver()", + " - @WebSocketRoute.Header()", + " - @WebSocketRoute.Param()", + " - @WebSocketRoute.Query()", + ].join("\n"), + ); + const versions: string[] = (() => { + if (props.config.versioning === undefined) return [""]; + const set = new Set([ + ...props.controller.versions, + ...getVersions(Reflect.getMetadata(VERSION_METADATA, props.method.value)), + ]); + const array = + set.size === 0 + ? getVersions(props.config.versioning.defaultVersion) + : Array.from(set); + return array.map((v) => + typeof v === "symbol" ? "" : `${props.config.versioning!.prefix}${v}`, + ); + })(); + for (const v of versions) + for (const cp of props.controller.prefixes) + for (const mp of route.paths) { + const parser: Path = new Path( + "/" + + [ + props.config.globalPrefix ?? "", + v, + props.controller.modulePrefix, + cp, + mp, + ] + .filter((str) => !!str.length) + .join("/") + .split("/") + .filter((str) => str.length) + .join("/"), + ); + const pathParams: IWebSocketRouteReflect.IParam[] = parameters.filter( + (p) => p.category === "param", + ) as IWebSocketRouteReflect.IParam[]; + if (parser.params.length !== pathParams.length) { + props.report( + [ + `Path "${parser}" must have same number of parameters with @WebSocketRoute.Param()`, + ` - path: ${JSON.stringify(parser.params)}`, + ` - arguments: ${JSON.stringify(pathParams.map((p) => p.field))}`, + ].join("\n"), + ); + continue; + } + const meet: boolean = pathParams + .map((p) => { + const has: boolean = parser.params.includes(p.field); + if (has === false) + props.report( + `Path "${parser}" must have parameter "${p.field}" with @WebSocketRoute.Param()`, + ); + return has; + }) + .every((b) => b); + if (meet === false) continue; + + props.operators.push({ + parser, + handler: async (input: { + params: Record; + acceptor: WebAcceptor; + }): Promise => { + const args: any[] = []; + try { + for (const p of parameters) + if (p.category === "acceptor") args.push(input.acceptor); + else if (p.category === "driver") + args.push(input.acceptor.getDriver()); + else if (p.category === "header") { + const error: Error | null = p.validate(input.acceptor.header); + if (error !== null) throw error; + args.push(input.acceptor.header); + } else if (p.category === "param") + args.push(p.assert(input.params[p.field])); + else if (p.category === "query") { + const query: any | Error = p.validate( + new URLSearchParams( + input.acceptor.path.indexOf("?") !== -1 + ? input.acceptor.path.split("?")[1] + : "", + ), + ); + if (query instanceof Error) throw query; + args.push(query); + } + } catch (exp) { + await input.acceptor.reject( + 1003, + exp instanceof Error + ? JSON.stringify({ ...exp }) + : "unknown error", + ); + return; + } + await props.method.value.call(props.controller.instance, ...args); + }, + }); + } +}; + +const getVersions = ( + value: VersionValue | undefined, +): Array => + value === undefined ? [] : Array.isArray(value) ? value : [value]; + +interface Entry { + key: string; + value: T; +} + +interface IController { + name: string; + versions: Array; + instance: object; + constructor: Function; + prototype: any; + prefixes: string[]; + modulePrefix: string; +} +interface IOperator { + parser: Path; + handler: (props: { + params: Record; + acceptor: WebAcceptor; + }) => Promise; +} +interface IConfig { + globalPrefix?: string; + versioning?: { + prefix: string; + defaultVersion?: VersionValue; + }; +} + +interface IControllerError { + name: string; + methods: IMethodError[]; +} +interface IMethodError { + name: string; + messages: string[]; + source: string; + line: number; + column: number; +} diff --git a/packages/core/src/decorators/TypedQuery.ts b/packages/core/src/decorators/TypedQuery.ts index fc16540a0..77bd59c94 100644 --- a/packages/core/src/decorators/TypedQuery.ts +++ b/packages/core/src/decorators/TypedQuery.ts @@ -46,7 +46,7 @@ import { validate_request_query } from "./internal/validate_request_query"; export function TypedQuery( validator?: IRequestQueryValidator, ): ParameterDecorator { - const checker = validate_request_query(validator); + const checker = validate_request_query("TypedQuery")(validator); return createParamDecorator(function TypedQuery( _unknown: any, context: ExecutionContext, @@ -70,7 +70,7 @@ export namespace TypedQuery { export function Body( validator?: IRequestQueryValidator, ): ParameterDecorator { - const checker = validate_request_query(validator); + const checker = validate_request_query("TypedQuery.Body")(validator); return createParamDecorator(function TypedQueryBody( _unknown: any, context: ExecutionContext, diff --git a/packages/core/src/decorators/WebSocketRoute.ts b/packages/core/src/decorators/WebSocketRoute.ts new file mode 100644 index 000000000..14bb3af62 --- /dev/null +++ b/packages/core/src/decorators/WebSocketRoute.ts @@ -0,0 +1,125 @@ +import { IRequestBodyValidator } from "../options/IRequestBodyValidator"; +import { IRequestQueryValidator } from "../options/IRequestQueryValidator"; +import { IWebSocketRouteReflect } from "./internal/IWebSocketRouteReflect"; +import { NoTransformConfigureError } from "./internal/NoTransformConfigureError"; +import { validate_request_body } from "./internal/validate_request_body"; +import { validate_request_query } from "./internal/validate_request_query"; + +export function WebSocketRoute( + path?: undefined | string | string[], +): MethodDecorator { + return function WebSocketRoute( + _target: Object, + _propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) { + Reflect.defineMetadata( + "nestia/WebSocketRoute", + { + paths: path === undefined ? [] : Array.isArray(path) ? path : [path], + } satisfies IWebSocketRouteReflect, + descriptor.value, + ); + return descriptor; + }; +} +export namespace WebSocketRoute { + export function Acceptor(): ParameterDecorator { + return function WebSocketAcceptor( + target: Object, + propertyKey: string | symbol | undefined, + parameterIndex: number, + ) { + emplace(target, propertyKey ?? "", { + category: "acceptor", + index: parameterIndex, + }); + }; + } + export function Driver(): ParameterDecorator { + return function WebSocketDriver( + target: Object, + propertyKey: string | symbol | undefined, + parameterIndex: number, + ) { + emplace(target, propertyKey ?? "", { + category: "driver", + index: parameterIndex, + }); + }; + } + + export function Header( + validator?: IRequestBodyValidator, + ): ParameterDecorator { + const validate = validate_request_body("WebSocketRoute.Header")(validator); + return function WebSocketHeader( + target: Object, + propertyKey: string | symbol | undefined, + parameterIndex: number, + ) { + emplace(target, propertyKey ?? "", { + category: "header", + index: parameterIndex, + validate, + }); + }; + } + + export function Param( + field: string, + assert?: (value: string) => T, + ): ParameterDecorator { + if (assert === undefined) + throw NoTransformConfigureError("WebSocketRoute.Param"); + return function WebSocketParam( + target: Object, + propertyKey: string | symbol | undefined, + parameterIndex: number, + ) { + emplace(target, propertyKey ?? "", { + category: "param", + index: parameterIndex, + field, + assert, + }); + }; + } + export function Query( + validator?: IRequestQueryValidator, + ): ParameterDecorator { + const validate = validate_request_query("WebSocketRoute.Query")(validator); + return function WebSocketQuery( + target: Object, + propertyKey: string | symbol | undefined, + parameterIndex: number, + ) { + emplace(target, propertyKey ?? "", { + category: "query", + index: parameterIndex, + validate, + }); + }; + } + + const emplace = ( + target: Object, + propertyKey: string | symbol, + value: IWebSocketRouteReflect.IArgument, + ) => { + const array: IWebSocketRouteReflect.IArgument[] | undefined = + Reflect.getMetadata( + "nestia/WebSocketRoute/Parameters", + target, + propertyKey, + ); + if (array !== undefined) array.push(value); + else + Reflect.defineMetadata( + "nestia/WebSocketRoute/Parameters", + [value], + target, + propertyKey, + ); + }; +} diff --git a/packages/core/src/decorators/internal/IWebSocketRouteReflect.ts b/packages/core/src/decorators/internal/IWebSocketRouteReflect.ts new file mode 100644 index 000000000..c6c6bfcbd --- /dev/null +++ b/packages/core/src/decorators/internal/IWebSocketRouteReflect.ts @@ -0,0 +1,23 @@ +export interface IWebSocketRouteReflect { + paths: string[]; +} +export namespace IWebSocketRouteReflect { + export type IArgument = IAcceptor | IDriver | IHeader | IParam | IQuery; + export interface IAcceptor extends IBase<"acceptor"> {} + export interface IDriver extends IBase<"driver"> {} + export interface IHeader extends IBase<"header"> { + validate: (input?: any) => Error | null; + } + export interface IParam extends IBase<"param"> { + field: string; + assert: (value: string) => any; + } + export interface IQuery extends IBase<"query"> { + validate: (input: URLSearchParams) => any | Error; + } + + interface IBase { + category: Category; + index: number; + } +} diff --git a/packages/core/src/decorators/internal/validate_request_body.ts b/packages/core/src/decorators/internal/validate_request_body.ts index 962692ccb..cd4539e9d 100644 --- a/packages/core/src/decorators/internal/validate_request_body.ts +++ b/packages/core/src/decorators/internal/validate_request_body.ts @@ -10,7 +10,7 @@ import { NoTransformConfigureError } from "./NoTransformConfigureError"; export const validate_request_body = (method: string) => (validator?: IRequestBodyValidator) => { - if (!validator) return () => NoTransformConfigureError(method); + if (!validator) throw NoTransformConfigureError(method); else if (validator.type === "assert") return assert(validator.assert); else if (validator.type === "is") return is(validator.is); else if (validator.type === "validate") return validate(validator.validate); diff --git a/packages/core/src/decorators/internal/validate_request_form_data.ts b/packages/core/src/decorators/internal/validate_request_form_data.ts index 4b3c7cd17..d9ddc758e 100644 --- a/packages/core/src/decorators/internal/validate_request_form_data.ts +++ b/packages/core/src/decorators/internal/validate_request_form_data.ts @@ -10,7 +10,7 @@ import { NoTransformConfigureError } from "./NoTransformConfigureError"; export const validate_request_form_data = ( props?: IRequestFormDataProps, ) => { - if (!props) return () => NoTransformConfigureError("TypedFormData.Bpdu"); + if (!props) throw NoTransformConfigureError("TypedFormData.Bpdu"); else if (props.validator.type === "assert") return assert(props.validator.assert); else if (props.validator.type === "is") return is(props.validator.is); diff --git a/packages/core/src/decorators/internal/validate_request_headers.ts b/packages/core/src/decorators/internal/validate_request_headers.ts index a09bd78d4..cb1e935dd 100644 --- a/packages/core/src/decorators/internal/validate_request_headers.ts +++ b/packages/core/src/decorators/internal/validate_request_headers.ts @@ -10,7 +10,7 @@ import { NoTransformConfigureError } from "./NoTransformConfigureError"; export const validate_request_headers = ( validator?: IRequestHeadersValidator, ) => { - if (!validator) return () => NoTransformConfigureError("TypedHeaders"); + if (!validator) throw NoTransformConfigureError("TypedHeaders"); else if (validator.type === "assert") return assert(validator.assert); else if (validator.type === "is") return is(validator.is); else if (validator.type === "validate") return validate(validator.validate); diff --git a/packages/core/src/decorators/internal/validate_request_query.ts b/packages/core/src/decorators/internal/validate_request_query.ts index ae7d1e0b2..cdbb489b9 100644 --- a/packages/core/src/decorators/internal/validate_request_query.ts +++ b/packages/core/src/decorators/internal/validate_request_query.ts @@ -7,16 +7,16 @@ import { NoTransformConfigureError } from "./NoTransformConfigureError"; /** * @internal */ -export const validate_request_query = ( - validator?: IRequestQueryValidator, -) => { - if (!validator) return () => NoTransformConfigureError("TypedQuery"); - else if (validator.type === "assert") return assert(validator.assert); - else if (validator.type === "is") return is(validator.is); - else if (validator.type === "validate") return validate(validator.validate); - return () => - new Error(`Error on nestia.core.TypedQuery(): invalid typed validator.`); -}; +export const validate_request_query = + (method: string) => + (validator?: IRequestQueryValidator) => { + if (!validator) throw NoTransformConfigureError(method); + else if (validator.type === "assert") return assert(validator.assert); + else if (validator.type === "is") return is(validator.is); + else if (validator.type === "validate") return validate(validator.validate); + return () => + new Error(`Error on nestia.core.${method}(): invalid typed validator.`); + }; /** * @internal diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts index 2de0bb8fb..7e206055f 100644 --- a/packages/core/src/module.ts +++ b/packages/core/src/module.ts @@ -1,16 +1,20 @@ export * from "./decorators/DynamicModule"; -export * from "./decorators/EncryptedBody"; -export * from "./decorators/EncryptedController"; -export * from "./decorators/EncryptedModule"; -export * from "./decorators/EncryptedRoute"; export * from "./utils/ExceptionManager"; -export * from "./decorators/PlainBody"; -export * from "./decorators/SwaggerCustomizer"; + +export * from "./decorators/TypedRoute"; export * from "./decorators/TypedBody"; +export * from "./decorators/TypedQuery"; export * from "./decorators/TypedException"; export * from "./decorators/TypedHeaders"; export * from "./decorators/TypedFormData"; export * from "./decorators/TypedParam"; -export * from "./decorators/TypedRoute"; -export * from "./decorators/TypedQuery"; -export * from "./options/INestiaTransformOptions"; + +export * from "./decorators/EncryptedController"; +export * from "./decorators/EncryptedRoute"; +export * from "./decorators/EncryptedBody"; +export * from "./decorators/EncryptedModule"; +export * from "./decorators/PlainBody"; +export * from "./decorators/SwaggerCustomizer"; + +export * from "./adaptors/WebSocketAdaptor"; +export * from "./decorators/WebSocketRoute"; diff --git a/packages/core/src/transformers/ParameterDecoratorTransformer.ts b/packages/core/src/transformers/ParameterDecoratorTransformer.ts index 75feec3fc..f0e97d062 100644 --- a/packages/core/src/transformers/ParameterDecoratorTransformer.ts +++ b/packages/core/src/transformers/ParameterDecoratorTransformer.ts @@ -101,6 +101,15 @@ const FUNCTORS: Record = { parameters.length ? parameters : [PlainBodyProgrammer.generate(project)(modulo)(type)], + "WebSocketRoute.Header": (project) => (modulo) => (parameters) => (type) => + parameters.length + ? parameters + : [TypedBodyProgrammer.generate(project)(modulo)(type)], + "WebSocketRoute.Param": (project) => TypedParamProgrammer.generate(project), + "WebSocketRoute.Query": (project) => (modulo) => (parameters) => (type) => + parameters.length + ? parameters + : [TypedQueryProgrammer.generate(project)(modulo)(type)], }; const LIB_PATH = path.join("@nestia", "core", "lib", "decorators"); diff --git a/packages/core/src/typings/get-function-location.d.ts b/packages/core/src/typings/get-function-location.d.ts new file mode 100644 index 000000000..a326881fc --- /dev/null +++ b/packages/core/src/typings/get-function-location.d.ts @@ -0,0 +1,7 @@ +declare module "get-function-location" { + export default function (func: any): Promise<{ + source: string; + line: number; + column: number; + }>; +} diff --git a/packages/core/src/utils/ArrayUtil.ts b/packages/core/src/utils/ArrayUtil.ts new file mode 100644 index 000000000..64e2b83cd --- /dev/null +++ b/packages/core/src/utils/ArrayUtil.ts @@ -0,0 +1,7 @@ +export namespace ArrayUtil { + export function has(array: T[], ...items: T[]): boolean { + return items.every( + (elem) => array.find((org) => org === elem) !== undefined, + ); + } +}