diff --git a/packages/app/src/renderer/app/components/environment-routes/environment-routes.component.html b/packages/app/src/renderer/app/components/environment-routes/environment-routes.component.html index d61b3072c..8b9f02109 100644 --- a/packages/app/src/renderer/app/components/environment-routes/environment-routes.component.html +++ b/packages/app/src/renderer/app/components/environment-routes/environment-routes.component.html @@ -90,6 +90,60 @@ /> + +
+ +
+
+ + + +
+ +
+ +
+
+
+ +
-
+
; public externalLink$: Observable; + public aliveModes: ToggleItems = [ + { + value: AliveMode.NONE, + icon: 'endpoint', + tooltip: 'No Streaming' + }, + { + value: AliveMode.SSE, + icon: 'announce', + tooltip: 'Event Stream (SSE)' + } + ]; public methods: DropdownItems = [ { value: Methods.all, @@ -644,12 +657,14 @@ export class EnvironmentRoutesComponent implements OnInit, OnDestroy { */ private initForms() { this.activeRouteForm = this.formBuilder.group({ + type: [RouteDefault.type], documentation: [RouteDefault.documentation], method: [RouteDefault.method], endpoint: [RouteDefault.endpoint], responseMode: [RouteDefault.responseMode], streamingMode: [RouteDefault.streamingMode], - streamingInterval: [RouteDefault.streamingInterval] + streamingInterval: [RouteDefault.streamingInterval], + aliveMode: [RouteDefault.aliveMode] }); this.defaultResponseTooltip$ = this.activeRouteForm @@ -673,9 +688,16 @@ export class EnvironmentRoutesComponent implements OnInit, OnDestroy { // send new activeRouteForm values to the store, one by one merge( ...Object.keys(this.activeRouteForm.controls).map((controlName) => - this.activeRouteForm - .get(controlName) - .valueChanges.pipe(map((newValue) => ({ [controlName]: newValue }))) + this.activeRouteForm.get(controlName).valueChanges.pipe( + map((newValue) => { + // when aliveMode property changes, switch active tab to 'RESPONSE' tab. + if (controlName === 'aliveMode') { + this.setActiveTab('RESPONSE'); + } + + return { [controlName]: newValue }; + }) + ) ) ) .pipe( @@ -738,7 +760,8 @@ export class EnvironmentRoutesComponent implements OnInit, OnDestroy { endpoint: activeRoute.endpoint, responseMode: activeRoute.responseMode, streamingMode: activeRoute.streamingMode, - streamingInterval: activeRoute.streamingInterval + streamingInterval: activeRoute.streamingInterval, + aliveMode: activeRoute.aliveMode }, { emitEvent: false } ); diff --git a/packages/app/src/renderer/app/components/menus/routes-menu/routes-menu.component.html b/packages/app/src/renderer/app/components/menus/routes-menu/routes-menu.component.html index f720b9a06..aeb1aa732 100644 --- a/packages/app/src/renderer/app/components/menus/routes-menu/routes-menu.component.html +++ b/packages/app/src/renderer/app/components/menus/routes-menu/routes-menu.component.html @@ -264,6 +264,15 @@ }} + @if (route.aliveMode === 'SSE') { + + } + @if (route.responseMode === ResponseMode.RANDOM) { - + + + - + + + - + + + - + + + - + + + - + + + + + + + TypedEmitter TypedEmitter { + this.generateRequestDatabuckets(route, this.environment, request); + + const checkRouteExistance = () => { + // refresh environment data to get route changes that do not require a restart (headers, body, etc) + const currentRoute = this.getRefreshedRoute(route); + + if (!currentRoute) { + this.emit('error', ServerErrorCodes.ROUTE_NO_LONGER_EXISTS, null, { + routePath: route.endpoint, + routeUUID: route.uuid + }); + + this.sendError(response, ServerMessages.ROUTE_NO_LONGER_EXISTS, 404); + + return null; + } + + return currentRoute; + }; + + let currentRoute = checkRouteExistance(); + + if (!currentRoute) { + return; + } + + this.requestNumbers[route.uuid] += 1; + + const enabledRouteResponse = new ResponseRulesInterpreter( + currentRoute.responses, + fromExpressRequest(request), + currentRoute.responseMode, + this.environment, + this.processedDatabuckets, + this.globalVariables, + this.options.envVarsPrefix + ).chooseResponse(this.requestNumbers[route.uuid]); + + if (!enabledRouteResponse) { + return next(); + } + + this.requestNumbers[route.uuid] += 1; + + // save route and response UUIDs for logs (only in desktop app) + if (route.uuid && enabledRouteResponse.uuid) { + response.routeUUID = route.uuid; + response.routeResponseUUID = enabledRouteResponse.uuid; + } + + let templateParse = true; + + // serve inline body as default + let content: any = enabledRouteResponse.body; + let parts: any[] | null = null; + const serverRequest = fromExpressRequest(request); + + if ( + enabledRouteResponse.bodyType === BodyTypes.FILE && + enabledRouteResponse.filePath + ) { + templateParse = false; + + parts = this.getFileLines( + route, + enabledRouteResponse, + serverRequest, + response + ); + + if (!parts) { + return; + } + } else if ( + enabledRouteResponse.bodyType === BodyTypes.DATABUCKET && + enabledRouteResponse.databucketID + ) { + templateParse = false; + + const servedDatabucket = this.processedDatabuckets.find( + (processedDatabucket) => + processedDatabucket.id === enabledRouteResponse.databucketID + ); + + if (servedDatabucket) { + content = servedDatabucket.value; + + if (Array.isArray(content)) { + parts = content as any[]; + } else if ( + typeof content === 'object' || + typeof content === 'boolean' || + typeof content === 'number' + ) { + parts = JSON.stringify(content).split(/\r\n|\r|\n/); + } else if (typeof content === 'string') { + parts = (content as string).split(/\r\n|\r|\n/); + } + } + } + + if (!enabledRouteResponse.disableTemplating && templateParse) { + content = TemplateParser({ + shouldOmitDataHelper: false, + content: content || '', + environment: this.environment, + processedDatabuckets: this.processedDatabuckets, + globalVariables: this.globalVariables, + request: serverRequest, + response, + envVarsPrefix: this.options.envVarsPrefix + }); + + parts = (content as string).split(/\r\n|\r|\n/); + } + + if (!parts) { + return; + } + + const sse = new Sse(); + sse.requestListener(request, response); + + let currentPartIndex = 0; + + const endResponse = (intRef: NodeJS.Timeout) => { + if (intRef) { + clearInterval(intRef); + } + sse.close(); + response.end(); + }; + + const intervalRef = setInterval(() => { + currentRoute = checkRouteExistance(); + if (!currentRoute) { + endResponse(intervalRef); + + return; + } + + sse.send(parts[currentPartIndex]); + + currentPartIndex++; + + if (currentPartIndex >= parts.length) { + endResponse(intervalRef); + } + + // do nothing + }, getSafeStreamingInterval(route.streamingInterval)); + + // close, if server instance closed before all responses returned + this.on('stopped', () => { + if (intervalRef) { + clearInterval(intervalRef); + } + sse.close(); + }); + }; + } + + private getFileLines( + route: Route, + enabledRouteResponse: RouteResponse, + serverRequest: ServerRequest, + response: Response + ): string[] | null { + try { + const filePath = this.getSafeFilePath( + enabledRouteResponse.filePath, + serverRequest + ); + + const fileMimeType = mimeTypeLookup(filePath) || ''; + + const data = readFileSync(filePath); + let fileContent: string; + if ( + MimeTypesWithTemplating.includes(fileMimeType) && + !enabledRouteResponse.disableTemplating + ) { + fileContent = TemplateParser({ + shouldOmitDataHelper: false, + content: data.toString(), + environment: this.environment, + processedDatabuckets: this.processedDatabuckets, + globalVariables: this.globalVariables, + request: serverRequest, + response, + envVarsPrefix: this.options.envVarsPrefix + }); + } else { + fileContent = data.toString(); + } + + return fileContent.split(/\r\n|\r|\n/); + } catch (error: any) { + this.emit('error', ServerErrorCodes.ROUTE_FILE_SERVING_ERROR, error, { + routePath: route.endpoint, + routeUUID: route.uuid + }); + this.sendError( + response, + format(ServerMessages.ROUTE_FILE_SERVING_ERROR, error.message) + ); + + return null; + } + } + private createRouteHandler(route: Route, crudId?: CrudRouteIds) { return (request: Request, response: Response, next: NextFunction) => { this.generateRequestDatabuckets(route, this.environment, request); diff --git a/packages/commons/src/constants/environment-schema.constants.ts b/packages/commons/src/constants/environment-schema.constants.ts index 9d2801026..c06539cc2 100644 --- a/packages/commons/src/constants/environment-schema.constants.ts +++ b/packages/commons/src/constants/environment-schema.constants.ts @@ -8,6 +8,7 @@ import { } from '../models/environment.model'; import { Folder, FolderChild } from '../models/folder.model'; import { + AliveMode, BodyTypes, CallbackInvocation, Header, @@ -73,7 +74,8 @@ export const RouteDefault: Route = { responses: [], responseMode: null, streamingMode: null, - streamingInterval: 0 + streamingInterval: 0, + aliveMode: AliveMode.NONE }; export const RouteResponseDefault: RouteResponse = { @@ -416,6 +418,68 @@ const CallbackInvocationSchemaBuilder = (fix: boolean) => stripUnknown: true }); +// export const RouteSchema = Joi.object({ +// uuid: UUIDSchema, +// type: Joi.string() +// .valid(RouteType.HTTP, RouteType.CRUD, RouteType.WS) +// .failover(RouteDefault.type) +// .required(), +// documentation: Joi.string() +// .allow('') +// .failover(RouteDefault.documentation) +// .required(), +// method: Joi.string() +// .allow('') +// .valid( +// Methods.all, +// Methods.get, +// Methods.post, +// Methods.put, +// Methods.patch, +// Methods.delete, +// Methods.head, +// Methods.options, +// Methods.propfind, +// Methods.proppatch, +// Methods.move, +// Methods.copy, +// Methods.mkcol, +// Methods.lock, +// Methods.unlock +// ) +// .failover(RouteDefault.method) +// .required(), +// endpoint: Joi.string().allow('').failover(RouteDefault.endpoint).required(), +// responses: Joi.array() +// .items(RouteResponseSchema, Joi.any().strip()) +// .failover(RouteDefault.responses) +// .required(), +// responseMode: Joi.string() +// .allow(null) +// .valid( +// ResponseMode.RANDOM, +// ResponseMode.SEQUENTIAL, +// ResponseMode.DISABLE_RULES, +// ResponseMode.FALLBACK +// ) +// .failover(RouteDefault.responseMode) +// .required(), +// streamingMode: Joi.string() +// .allow(null) +// .valid(StreamingMode.UNICAST, StreamingMode.BROADCAST) +// .failover(RouteDefault.streamingMode) +// .required(), +// streamingInterval: Joi.number() +// .min(0) +// .failover(RouteDefault.streamingInterval) +// .required(), +// aliveMode: Joi.string() +// .allow(null) +// .valid(AliveMode.NONE, AliveMode.SSE) +// .failover(RouteDefault.aliveMode) +// .required() +// }); + const RouteResponseSchemaBuilder = (fix: boolean) => conditionalFailover( fix, @@ -629,6 +693,15 @@ const RouteSchemaBuilder = (fix: boolean) => fix, Joi.number().min(0).required(), RouteDefault.streamingInterval + ), + aliveMode: conditionalFailover( + fix, + Joi.string() + .allow(null) + .valid(AliveMode.NONE, AliveMode.SSE) + .failover(RouteDefault.aliveMode) + .required(), + RouteDefault.aliveMode ) }).options({ stripUnknown: true diff --git a/packages/commons/src/models/route.model.ts b/packages/commons/src/models/route.model.ts index b62d65548..19d94619e 100644 --- a/packages/commons/src/models/route.model.ts +++ b/packages/commons/src/models/route.model.ts @@ -93,6 +93,11 @@ export enum RouteType { WS = 'ws' } +export enum AliveMode { + NONE = 'NONE', + SSE = 'SSE' +} + export type Route = { uuid: string; type: RouteType; @@ -103,7 +108,10 @@ export type Route = { responseMode: ResponseMode | null; // used in websocket routes streamingMode: StreamingMode | null; + // used in both websockets and Http/SSE routes streamingInterval: number; + // used to identify Http/SSE routes + aliveMode: AliveMode | null; }; export type Header = { key: string; value: string };