import { ParserInterface, Schema, InferSchema } from "@miqro/parser"; import { SerializeOptions as CookieSerializeOptions, parse, serialize } from "cookie"; import { createHash, randomUUID } from "node:crypto"; import { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from "node:http"; import { Socket } from "node:net"; import { Duplex } from "node:stream"; import { Logger } from "./common.js"; import { DEFAULT_TOKEN_SET_COOKIE_HTTP_ONLY, DEFAULT_TOKEN_SET_COOKIE_PATH, DEFAULT_TOKEN_SET_COOKIE_SAME_SITE, DEFAULT_TOKEN_SET_COOKIE_SECURE } from "./middleware/session.js"; /** * Types */ export interface LoggerErrorEventInit { bubbles?: boolean; cancelable?: boolean; composed?: boolean; error: any; } export class LoggerErrorEvent extends Event { public error: any; constructor(event: string, init: LoggerErrorEventInit) { super(event, init); this.error = init.error; } } export type LogLevel = "error" | "warn" | "info" | "debug" | "trace" | "none"; export interface WriteArgs { level: LogLevel; message?: any; identifier: string; optionalParams: any[]; meta: any[]; } export interface LoggerTransportWriteArgs extends WriteArgs { out: string; } export interface LoggerTransport { level?: LogLevel; write(args: LoggerTransportWriteArgs): Promise | void; } export type LoggerFormatter = (args: WriteArgs) => string; export interface MinimalLogger { log(message?: any, ...optionalParams: any[]): void; info(message?: any, ...optionalParams: any[]): void; trace(message?: any, ...optionalParams: any[]): void; debug(message?: any, ...optionalParams: any[]): void; warn(message?: any, ...optionalParams: any[]): void; error(message?: any, ...optionalParams: any[]): void; } export type ConfigOutput = { [name: string]: string }; export interface LoadConfigOut { combined: ConfigOutput; outputs: ConfigOutput[] } export interface LoggerFactoryArgs { identifier: string, level: LogLevel; options?: { transports?: LoggerTransport[]; formatter?: LoggerFormatter; } } export type LoggerFactory = (args: LoggerFactoryArgs) => Logger; export const GroupPolicySchema: Schema = { type: "object", properties: { groups: { type: "array", arrayType: "string|string[]" }, groupPolicy: { type: "enum", enumValues: ["at_least_one", "all"] } } } export interface SessionHandlerOptions { authService: { verify(args: { token?: string | null; req: Request; res: Response }): Promise; }; options?: SessionHandlerOptionsOptions; } export interface SessionHandlerOptionsOptions { tokenLocation: "header" | "query" | "cookie" | "free"; tokenLocationName?: string | ((req: Request) => Promise); setCookieOptions?: { httpOnly: boolean; secure: boolean; path: string | ((req: Request) => Promise); sameSite: "lax" | "strict" | "none"; } } export const SessionHandlerOptionsOptionsSchema: Schema = { type: "object", properties: { tokenLocation: { type: "enum", defaultValue: "free", enumValues: ["header", "query", "cookie", "free"] }, tokenLocationName: "string?|function?", setCookieOptions: { type: "object?", defaultValue: Object.freeze({ httpOnly: DEFAULT_TOKEN_SET_COOKIE_HTTP_ONLY === "true", secure: DEFAULT_TOKEN_SET_COOKIE_SECURE === "true", path: DEFAULT_TOKEN_SET_COOKIE_PATH, sameSite: DEFAULT_TOKEN_SET_COOKIE_SAME_SITE }), properties: { httpOnly: "boolean", secure: "boolean", path: "string|function", sameSite: { type: "enum", enumValues: ["lax", "strict", "none"] } } } } } export const SessionHandlerOptionsSchema: Schema = { type: "object", properties: { authService: { type: "object", properties: { verify: "function" } }, options: { type: "object?", properties: SessionHandlerOptionsOptionsSchema.properties } } }; type Dict = { [key: string]: T } export interface NoTokenSession extends Dict { account: string; username: string; groups: string[]; } export interface Session extends NoTokenSession { token: string; } export type GroupPolicyType = "at_least_one" | "all"; export type GroupPolicyGroups = string | string[]; export interface GroupPolicy { groups: GroupPolicyGroups[]; groupPolicy: GroupPolicyType; } export type Method = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"; //export const IGNORE_HEADER = ["keep-alive", "connection"]; export interface HandlerResponse { status?: number; headers?: OutgoingHttpHeaders; body?: any; } export type Handler = ((req: Request, res: Response) => Promise) | ((req: Request, res: Response) => T); export type ErrorHandler = ((e: unknown, req: Request, res: Response) => Promise) | ((e: unknown, req: Request, res: Response) => T); export type SchemaProperties = { [key: string]: Schema | string; }; const OptionalSchema: Schema = { type: "string?|dict[]!?", dictType: "Schema|string" } const OptionalSchemaWithBoolean: Schema = { type: "boolean?|string?|dict[]!?", dictType: "Schema|string" } export type ParserMode = "remove_extra" | "add_extra" | "no_extra"; const OptionalParserModeSchema: Schema = { type: "enum?", enumValues: ["remove_extra", "add_extra", "no_extra"] } export const RouteOptionsSchema: Schema = { type: "object", properties: { name: "string?", description: "string?", identifier: "string?", apiName: "string?", policy: { type: "object?", properties: GroupPolicySchema.properties }, response: { type: "object?|boolean?", properties: { etag: "boolean?|function?", status: "number[]!?", headers: OptionalSchema, headersMode: OptionalParserModeSchema, body: OptionalSchemaWithBoolean, bodyMode: OptionalParserModeSchema, middleware: "function[]!?" } }, request: { type: "object?", properties: { headers: OptionalSchema, headersMode: OptionalParserModeSchema, query: OptionalSchemaWithBoolean, queryMode: OptionalParserModeSchema, params: OptionalSchemaWithBoolean, paramsMode: OptionalParserModeSchema, body: OptionalSchemaWithBoolean, bodyMode: OptionalParserModeSchema } } } }; export interface RouteOptions< TBody extends SchemaProperties | string | boolean | undefined = undefined, TParams extends SchemaProperties | string | boolean | undefined = undefined, TQuery extends SchemaProperties | string | boolean | undefined = undefined > { name?: string; identifier?: string; apiName?: string; description?: string; policy?: GroupPolicy; request?: { body?: TBody; params?: TParams; query?: TQuery; bodyMode?: ParserMode; queryMode?: ParserMode; paramsMode?: ParserMode; headers?: string | SchemaProperties[] | SchemaProperties; headersMode?: ParserMode; }; response?: { etag?: boolean | (() => Promise | string); status?: number | number[]; headers?: string | SchemaProperties | SchemaProperties[]; headersMode?: ParserMode; body?: boolean | string | SchemaProperties | SchemaProperties[]; bodyMode?: ParserMode; middleware?: Handler | Handler[]; } | boolean; } export interface RouteJSONDoc extends RouteOptions { identifier: string; } export interface RouterJSONDoc { [path: string]: { [method: string]: RouteJSONDoc[] } } export const RouterHandlerOptionsSchema: Schema = { type: "object", properties: { ...RouteOptionsSchema.properties, parser: { type: "object?", properties: { parse: "function" }, mode: "add_extra" }, middleware: "function[]!?", session: { type: "object?|function?", properties: SessionHandlerOptionsSchema.properties } } }; export interface RouterHandlerOptions< TBody extends SchemaProperties | string | boolean | undefined = undefined, TParams extends SchemaProperties | string | boolean | undefined = undefined, TQuery extends SchemaProperties | string | boolean | undefined = undefined > extends RouteOptions { parser?: ParserInterface; middleware?: Handler | Handler[]; session?: SessionHandlerOptions | Handler; } /** * Infer the TypeScript type produced by a request-part schema (body / params / query). * - string shorthand → `InferSchema` * - plain properties object `{ name: "string", age: "integer" }` → object with inferred fields * - `boolean | undefined` → `any` (pass-through, no type enforcement) */ export type InferRequestPart = [T] extends [boolean | undefined | null] ? any : [T] extends [string] ? InferSchema : T extends SchemaProperties ? InferSchema<{ type: "object"; properties: T }> : any; /** * A `Request` narrowed to the typed `body`, `params`, and `query` shapes inferred * from the schemas declared on a route's `request` option. */ export type TypedRequest< TBody = any, TParams = any, TQuery = any, BaseRequest extends Request = Request > = Omit & { body?: TBody; // optional to stay compatible with Request — ParseRequest validates it at runtime params: TParams; query: TQuery; }; export const HandlerWithOptionsSchema: Schema = { type: "object", properties: { ...RouterHandlerOptionsSchema.properties, handler: "function[]!" } }; export interface HandlerWithOptions< TBody extends SchemaProperties | string | boolean | undefined = undefined, TParams extends SchemaProperties | string | boolean | undefined = undefined, TQuery extends SchemaProperties | string | boolean | undefined = undefined, BaseRequest extends Request = Request > extends RouterHandlerOptions { handler: | ((req: TypedRequest, InferRequestPart, InferRequestPart, BaseRequest>, res: Response) => Promise) | ((req: TypedRequest, InferRequestPart, InferRequestPart, BaseRequest>, res: Response) => boolean | void | HandlerResponse) | Array; } export interface RouterOptions { loggerFactory?: (uuid: string, req: Request) => MinimalLogger; etag?: boolean | ((lastResult?: { headers?: any; body?: any; status?: any; }) => Promise | string | false | null | undefined); } export interface MinimalRouter { run: (req: Request, res: Response, pathTokens?: PathPart[], prePathTokens?: PathPartToken[]) => Promise; getJSONDoc: (doc?: RouterJSONDoc, prePath?: string) => RouterJSONDoc; } export const MinimalRouterSchema: Schema = { type: "object", properties: { run: "function", getJSONDoc: "function" }, mode: "add_extra" } export function isMinimalRouter(obj: any) { return typeof obj === "object" && typeof obj.run === "function" && typeof obj.getJSONDoc === "function"; } /** * # APIRoute * * src/api/comment/get.js * ```typescript *import { APIRouter } from "@miqro/core"; *const route: APIRoute = { * ... *}; *export default route; * ``` * * Use the type parameters to get typed `req.body`, `req.params`, and `req.query` * in the handler. Use `defineRoute()` for automatic schema inference. */ export interface APIRoute< BaseRequest extends Request = Request, TBody extends SchemaProperties | string | boolean | undefined = undefined, TParams extends SchemaProperties | string | boolean | undefined = undefined, TQuery extends SchemaProperties | string | boolean | undefined = undefined, > extends Omit { postFolder?: boolean; basePath?: string; method?: Method[] | Method | "use" | "USE"; path?: string[] | string | null; ignore?: boolean; request?: { body?: TBody; params?: TParams; query?: TQuery; bodyMode?: ParserMode; queryMode?: ParserMode; paramsMode?: ParserMode; headers?: string | SchemaProperties[] | SchemaProperties; headersMode?: ParserMode; }; handler: | ((req: TypedRequest, InferRequestPart, InferRequestPart, BaseRequest>, res: Response) => Promise) | ((req: TypedRequest, InferRequestPart, InferRequestPart, BaseRequest>, res: Response) => boolean | void | HandlerResponse) | Array | MinimalRouter; init?: (route: APIRoute) => Promise; } /** * Type-safe factory for file-based API routes (`export default`). * * Wrap your route object so TypeScript automatically infers `req.body`, * `req.params`, and `req.query` from the schemas in `request`. * * @example * export default defineRoute({ * request: { body: { name: "string", age: "integer" } }, * handler: async (req) => { * req.body.name; // string * req.body.age; // number * } * }); */ export function defineRoute< BaseRequest extends Request = Request, const TBody extends SchemaProperties | string | boolean | undefined = undefined, const TParams extends SchemaProperties | string | boolean | undefined = undefined, const TQuery extends SchemaProperties | string | boolean | undefined = undefined, >(route: APIRoute): APIRoute { return route as unknown as APIRoute; } export interface APIRouterOptions { dirname: string; apiName?: string; path?: string; ignore?: Array; loader?: (path: string) => Promise; extensions?: string[]; } export const APIRouterOptionsSchema: Schema = { type: "object", properties: { dirname: "string", apiName: "string?", path: "string?", ignore: { type: "array?", arrayType: "string|RegExp" }, loader: "function?", extensions: "string[]?", } }; export const APIRouteSchema: Schema = { type: "object", properties: { ...RouterHandlerOptionsSchema.properties, postFolder: "boolean?", basePath: "string?", method: "string?|string[]?", path: { type: "string?|string[]?", allowNull: true }, ignore: "boolean?", handler: { type: "function[]!|object", properties: MinimalRouterSchema.properties, mode: MinimalRouterSchema.mode }, init: "function?" } }; export interface Request extends IncomingMessage { startMS: number; logger: MinimalLogger; session?: Session; uuid: string; path: string; // normalized path hash: string; // from this.url.hash cookies: { [name: string]: string }; params: { [name: string]: string | undefined }; // the router will fill this buffer?: Buffer; // empty buffer. middleware must read it query: { [name: string]: string | string[] }; results: any[]; // handlers will fill this body?: any; // a middleware will fill this reading the buffer } export class Request extends IncomingMessage implements Request { startMS: number; body?: any; // a middleware will fill this reading the buffer logger: MinimalLogger; uuid: string; path: string; // normalized path hash: string; // from url.hash searchParams: URLSearchParams;// from url.searchParams cookies: { [name: string]: string }; params: { [name: string]: string | undefined }; // the router will fill this buffer?: Buffer; // empty buffer. middleware must read it query: { [name: string]: string | string[] }; results: any[]; // handlers will fill this constructor(socket: Socket) { super(socket); this.logger = console; this.path = null as any; this.hash = null as any; this.searchParams = null as any; this.query = null as any; this.cookies = Object.create(null); this.params = Object.create(null); this.results = []; this.startMS = Date.now(); this.uuid = randomUUID(); let vURL: string | undefined; Object.defineProperty(this, "url", { get: () => { return vURL; }, set: (v) => { vURL = v; const url = newURL(vURL ? vURL : "/") as URL; this.searchParams = url.searchParams; this.path = normalizePath(url.pathname); // normalized path this.hash = url.hash; this.query = parseSearchParams(this.searchParams); } }); let vCookie: string | undefined; Object.defineProperty(this.headers, "cookie", { get: () => { return vCookie; }, set: (v) => { vCookie = v; this.cookies = Object.create(null); const cookies = parse(vCookie || ''); const cookieList = Object.keys(cookies); for (const name of cookieList) { const value = cookies[name]; if (value) { this.cookies[name] = value; } } } }); let vMethod: string | undefined; Object.defineProperty(this, "method", { get: () => { return vMethod; }, set: (v?: string | undefined) => { vMethod = v ? v.toLowerCase() : v; } }); } } export interface Response extends ServerResponse { //logger: Logger; asyncClose(): Promise; asyncEnd(args?: { body?: any; status?: number; headers?: OutgoingHttpHeaders; }): Promise; asyncWrite(chunk: any): Promise; json(body: any, headers?: OutgoingHttpHeaders, status?: number): Promise; addVaryHeader(value: number | string | ReadonlyArray): ServerResponse; setCookie(name: string, value: string, options?: CookieSerializeOptions): ServerResponse; html(html: string, headers?: OutgoingHttpHeaders, status?: number): Promise; css(css: string, headers?: OutgoingHttpHeaders, status?: number): Promise; js(js: string, headers?: OutgoingHttpHeaders, status?: number): Promise; text(text: string, headers?: OutgoingHttpHeaders, status?: number): Promise; redirect(url: string, headers?: OutgoingHttpHeaders, status?: number): Promise; } function defaultETag({ body }: { body?: string | Buffer | Object | null }) { return body ? `"${createHash('sha1').update(body instanceof Buffer ? body : typeof body === "object" ? JSON.stringify(body) : String(body)).digest('hex').slice(0, 16)}"` : null }; export class Response extends ServerResponse implements Response { public static IGNORE_HEADER = ["keep-alive", "connection"]; public useETag: boolean | ((lastResult?: { headers?: any; body?: any; status?: any; }) => Promise | string | false | null | undefined) = false; //logger: Logger; constructor(req: Request) { super(req); //this.logger = null as any; this.removeHeader("Date"); } setETag(useETag: boolean | ((lastResult?: { headers?: any; body?: any; status?: any; }) => Promise | string | false | null | undefined)) { this.useETag = useETag; } async redirect(url: string, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 302, headers: { ['Location']: url, ...headers } }) } async html(html: string, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 200, headers: { ['Content-Type']: 'text/html; charset=utf-8', ...headers }, body: html }) } async css(css: string, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 200, headers: { ['Content-Type']: 'text/css; charset=utf-8', ...headers }, body: css }) } async js(js: string, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 200, headers: { ['Content-Type']: 'text/javascript; charset=utf-8', ...headers }, body: js }) } async text(text: string, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 200, headers: { ['Content-Type']: 'text/plain; charset=utf-8', ...headers }, body: text }); } public setCookie(name: string, value: string, options?: CookieSerializeOptions): ServerResponse { return this.setHeader('Set-Cookie', serialize(name, String(value), options)); } addVaryHeader(value: number | string | ReadonlyArray): ServerResponse { const c = this.getHeader("Vary"); const current = c ? c instanceof Array ? c.join(", ") : String(c) : ""; const nV = value instanceof Array ? value.join(", ") : String(value); const newValue = current ? `${current}, ${nV}` : nV; return this.setHeader("Vary", newValue.indexOf("*") !== -1 ? "*" : newValue); } async asyncClose(status: number = 400): Promise { return this.asyncEnd({ status, headers: { Connection: "close" } }); } async json(body: any, headers?: OutgoingHttpHeaders, status?: unknown): Promise { return this.asyncEnd({ status: status !== undefined ? status : 200, headers: { ['Content-Type']: 'application/json; charset=utf-8', ...headers, }, body: JSON.stringify(body) }) } public async asyncWrite(chunk: any): Promise { return new Promise((resolve, reject) => { this.write(chunk, function (error?: Error | null) { if (error) { reject(error); } else { resolve(); } }); }); } public async asyncEnd(args?: { body?: unknown; status?: unknown; headers?: OutgoingHttpHeaders; }): Promise { if (this.headersSent) { return Promise.reject(new Error("already ended")); } return new Promise(async (resolve, reject) => { try { if (this.headersSent) { reject(new Error("already ended")); return; } else { const body = args ? args.body : undefined; const nBody = body !== undefined ? (body instanceof Buffer ? body : String(body)) : null; if (args !== undefined) { const { status, headers } = args; const nStatus = status !== undefined ? parseInt(String(status), 10) : 200; if (isNaN(nStatus)) { reject(new Error("status not a number!")); return; } else { if (this.useETag) { let incomingETag = this.req.headers['if-none-match']; if (incomingETag && incomingETag.indexOf("W/") === 0) { incomingETag = incomingETag.substring("W/".length); } const etag = typeof this.useETag === "function" ? await this.useETag(args) : defaultETag({ body: nBody }); if (incomingETag && etag && etag === incomingETag && nStatus >= 200 && nStatus < 300) { this.statusCode = 304; this.setHeader("ETag", etag); this.end(() => { resolve(); }) return; } else if (etag) { this.setHeader("ETag", etag); } } //console.log("nStatus = [%s][%s]", nStatus, typeof nStatus); this.statusCode = nStatus; if (headers) { const keys = Object.keys(headers); for (const key of keys) { if (Response.IGNORE_HEADER.indexOf(key.toLocaleLowerCase()) !== -1) { //this.logger.warn("ignoring " + key); continue; } if (headers[key] !== undefined) { this.setHeader(key, headers[key] as any); } } } } } this.setHeader("Connection", "close"); //console.log("body = [%s][%s]", nBody, typeof nBody); this.end(nBody, () => { resolve(); }); } } catch (e) { reject(e); } }); } } export interface PathParams { [name: string]: string | undefined } export interface PathPartToken extends PathPart { optional: boolean; wild?: string; } export interface PathPart { value: string; lower: string; } function pathPartTokenize(token: PathPart): PathPartToken { const isWild = token.value[0] === ":"; const optional = token.value[token.value.length - 1] === "?"; const wild = isWild ? token.value.substring(1, token.value.length - (optional ? 1 : 0)) : undefined; return { optional, wild, value: token.value, lower: token.lower } } export const splitPath = (path?: string): PathPart[] => path === undefined ? [] : path.split("/").filter(p => p).map(p => { return { value: p, lower: p.toLocaleLowerCase() }; }); export function tokenizePath(path?: string): PathPartToken[] { if (path === undefined) { return []; } const tokens = splitPath(path).map(token => pathPartTokenize(token)); for (let i = 0; i < tokens.length; i++) { if (tokens[i].optional && i != tokens.length - 1) { throw new Error("cannot set a path token as optional that's not the last one"); } } return tokens; } export function matchTokenizePath(checkAsRouter: boolean, pathTokens: PathPartToken[], requestParts: PathPart[]): { match: boolean; params?: { [name: string]: string | undefined } } { for (let i = 0; i < pathTokens.length; i++) { if (pathTokens[i].optional && i != pathTokens.length - 1) { throw new Error("cannot set a path token as optional that's not the last one"); } } const lastTokenIsOptional = pathTokens.length > 0 ? pathTokens[pathTokens.length - 1].optional : false; const couldBeUsingOptional = (lastTokenIsOptional && ( (!checkAsRouter && pathTokens.length - 1 === requestParts.length) || (checkAsRouter && pathTokens.length - 1 <= requestParts.length) ) ); const params: { [name: string]: string | undefined } = Object.create(null); if ((!checkAsRouter && pathTokens.length === requestParts.length) || couldBeUsingOptional || (checkAsRouter && pathTokens.length <= requestParts.length)) { // similar count of tokens for (let i = 0; i < requestParts.length; i++) { const ctxToken = requestParts[i]; if (checkAsRouter && i >= pathTokens.length) { break; } if (pathTokens[i].wild || ctxToken.lower === pathTokens[i].lower) { // check pass const wild = pathTokens[i].wild; if (wild) { params[wild] = ctxToken.value; } continue; } //console.log("matchTokenizePath(%s, %o, %o)=%s", checkAsRouter, pathTokens, requestParts, false); return { match: false, params: {} }; } //console.log("matchTokenizePath(%s, %o, %o)=%s", checkAsRouter, pathTokens, requestParts, true); return { match: true, params }; } else { //console.log("matchTokenizePath(%s, %o, %o)=%s", checkAsRouter, pathTokens, requestParts, false); return { match: false, params: {} }; } } export function newURL(input: string): URL { /* remove /+ at the start of the input before using new URL ( it changes the host ) */ const slashIndex = getSlashIndex(input); const removedSlashes = input.substring(0, slashIndex - 1); const ret = new URL(input.substring(slashIndex - 1), "http://localhost"); // restore path ret.pathname = `${slashIndex > 0 ? removedSlashes : ""}${ret.pathname}`; return ret; } /** * * @param input a url.pathname * @returns the pathname ignoring start slashes for example if sended "///page/hi" will return /page/hi */ export function removeStartingBackSlashes(input: string): string { /* remove /+ at the start of the input before using new URL ( it changes the host ) */ const slashIndex = getSlashIndex(input); return input.substring(slashIndex - 1); } /** * * @param input a url.pathname * @returns the first real slashindex for example if sended "///page/hi" will return 4 */ export function getSlashIndex(input: string): number { let slashIndex = 0 for (slashIndex = 0; slashIndex < input.length; slashIndex++) { if (input.charAt(slashIndex) !== "/") { break; } } return slashIndex; } /** * checks the input as a string that starts with / * @param path input path * @returns the same path if it passed the checks */ export function normalizePath(path: string): string { if (typeof path !== "string") { throw new Error("path not string"); } if (path.length > 1) { if (path.charAt(0) !== "/") { throw new Error("path doesnt start with /"); } return path; } else if (path !== "/") { throw new Error("path of length 1 not /"); } else { return path; // "/" } } export function parseSearchParams(search: URLSearchParams): { [name: string]: string | string[] } { const query: { [name: string]: string | string[] } = Object.create(null); search.forEach((value: string, key: string) => { query[key] = query[key] instanceof Array ? (query[key] as string[]).concat([value]) : query[key] ? [query[key] as string, value] : value; }); return query; } export interface ConnectionUpgrader { onUpgrade: (req: Request, socket: Duplex, head: Buffer) => Promise | void; } export interface WebSocketComplexValidateResult { uuid?: string; headers?: { name: string; value: string; }[]; } export interface WebSocketServerOptions { maxConnections?: number; maxFrameSize?: number; validate?: (req: Request) => Promise | boolean | string | WebSocketComplexValidateResult; onConnection?: (req: WebSocketClient) => void | Promise; onMessage?: (req: WebSocketClient, data: string | undefined | null) => void | Promise; onDisconnect?: (req: WebSocketClient) => void | Promise; onError?: (req: WebSocketClient, error: Error) => void | Promise; } export interface WebSocketClient { uuid: string; req: Request; socket: Duplex; head: Buffer; }