From c7c389413e258ad0904ca83881c372815593ffb4 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 30 Jun 2026 16:26:40 -0300 Subject: [PATCH 1/4] refactor(base-runtime): extract shared base-runtime from node/deno trees Move the 52 shared runtime files (handlers, accessors, lib, AST, AppObjectRegistry, and their tests) into a single base-runtime/ tree, deleting the duplicated copies that lived in both node-runtime/src and deno-runtime. The base is now the single source for everything that is not a platform seam. Three base-only changes make the tree self-contained: - construct.ts becomes single-source: sandbox require and the eval-shell globals are injected via setSandboxRequire/setSandboxGlobals (defaults no-op), the App import is type-only, and the platform Socket patch is removed (hoisted to the Deno adapter bootstrap). - the message loop is extracted to messageLoop.ts (startMessageLoop), leaving transport/error-listener wiring to each adapter. - the anonymous Queue class in messenger.ts is named so the base can emit declarations; the base messenger test no longer references a platform transport. base-runtime/tsconfig.json compiles the tree with the runtime-flavored settings (nodenext, es2023, types: node) to base-runtime/dist. Co-Authored-By: Claude Opus 4.8 (1M context) refactor(base-runtime): messageLoop to mainLoop refactor(base-runtime): better directory structure install base-runtime dependencies in the apps package fix(base-runtime): import fixes refactor(base-runtime): move the full require logic out of construct.ts Module resolution is the responsibility of the platform --- .../base-runtime/src/AppObjectRegistry.ts | 25 + .../base-runtime/src/handlers/api-handler.ts | 51 ++ .../src/handlers/app/construct.ts | 130 +++++ .../src/handlers/app/handleGetStatus.ts | 16 + .../src/handlers/app/handleInitialize.ts | 24 + .../src/handlers/app/handleOnDisable.ts | 20 + .../src/handlers/app/handleOnEnable.ts | 22 + .../src/handlers/app/handleOnInstall.ts | 34 ++ .../handlers/app/handleOnPreSettingUpdate.ts | 31 ++ .../handlers/app/handleOnSettingUpdated.ts | 33 ++ .../src/handlers/app/handleOnUninstall.ts | 34 ++ .../src/handlers/app/handleOnUpdate.ts | 34 ++ .../src/handlers/app/handleSetStatus.ts | 28 + .../src/handlers/app/handleUploadEvents.ts | 81 +++ .../base-runtime/src/handlers/app/handler.ts | 116 ++++ .../src/handlers/lib/assertions.ts | 51 ++ .../src/handlers/listener/handler.ts | 153 ++++++ .../src/handlers/outboundcomms-handler.ts | 38 ++ .../src/handlers/scheduler-handler.ts | 66 +++ .../src/handlers/slashcommand-handler.ts | 123 +++++ .../src/handlers/tests/api-handler.test.ts | 176 ++++++ .../src/handlers/tests/helpers/mod.ts | 29 + .../handlers/tests/listener-handler.test.ts | 225 ++++++++ .../handlers/tests/scheduler-handler.test.ts | 41 ++ .../tests/slashcommand-handler.test.ts | 162 ++++++ .../src/handlers/tests/uikit-handler.test.ts | 105 ++++ .../tests/upload-event-handler.test.ts | 123 +++++ .../tests/videoconference-handler.test.ts | 112 ++++ .../src/handlers/uikit/handler.ts | 101 ++++ .../src/handlers/videoconference-handler.ts | 53 ++ .../lib/accessors/builders/BlockBuilder.ts | 16 + .../accessors/builders/DiscussionBuilder.ts | 48 ++ .../builders/LivechatMessageBuilder.ts | 196 +++++++ .../lib/accessors/builders/MessageBuilder.ts | 267 +++++++++ .../src/lib/accessors/builders/RoomBuilder.ts | 191 +++++++ .../src/lib/accessors/builders/UserBuilder.ts | 75 +++ .../builders/VideoConferenceBuilder.ts | 87 +++ .../lib/accessors/extenders/HttpExtender.ts | 58 ++ .../accessors/extenders/MessageExtender.ts | 60 ++ .../lib/accessors/extenders/RoomExtender.ts | 55 ++ .../extenders/VideoConferenceExtend.ts | 63 +++ .../accessors/formatResponseErrorHandler.ts | 14 + .../base-runtime/src/lib/accessors/http.ts | 95 ++++ .../base-runtime/src/lib/accessors/mod.ts | 356 ++++++++++++ .../src/lib/accessors/modify/ModifyCreator.ts | 380 +++++++++++++ .../lib/accessors/modify/ModifyExtender.ts | 101 ++++ .../src/lib/accessors/modify/ModifyUpdater.ts | 164 ++++++ .../src/lib/accessors/notifier.ts | 80 +++ .../lib/accessors/tests/AppAccessors.test.ts | 134 +++++ .../lib/accessors/tests/ModifyCreator.test.ts | 276 ++++++++++ .../accessors/tests/ModifyExtender.test.ts | 235 ++++++++ .../lib/accessors/tests/ModifyUpdater.test.ts | 238 ++++++++ .../tests/formatResponseErrorHandler.test.ts | 211 +++++++ .../src/lib/accessors/tests/http.test.ts | 156 ++++++ packages/apps/base-runtime/src/lib/ast/mod.ts | 69 +++ .../base-runtime/src/lib/ast/operations.ts | 237 ++++++++ .../src/lib/ast/tests/data/ast_blocks.ts | 516 ++++++++++++++++++ .../src/lib/ast/tests/operations.test.ts | 262 +++++++++ packages/apps/base-runtime/src/lib/codec.ts | 50 ++ packages/apps/base-runtime/src/lib/logger.ts | 164 ++++++ .../apps/base-runtime/src/lib/messenger.ts | 206 +++++++ .../base-runtime/src/lib/metricsCollector.ts | 21 + .../apps/base-runtime/src/lib/parseArgs.ts | 29 + .../base-runtime/src/lib/requestContext.ts | 10 + packages/apps/base-runtime/src/lib/room.ts | 118 ++++ .../apps/base-runtime/src/lib/roomFactory.ts | 29 + .../src/lib/sanitizeDeprecatedUsage.ts | 20 + .../apps/base-runtime/src/lib/secureFields.ts | 27 + .../base-runtime/src/lib/tests/logger.test.ts | 111 ++++ .../src/lib/tests/messenger.test.ts | 79 +++ .../src/lib/tests/secureFields.test.ts | 57 ++ .../base-runtime/src/lib/wrapAppForRequest.ts | 60 ++ packages/apps/base-runtime/src/mainLoop.ts | 127 +++++ packages/apps/base-runtime/tsconfig.json | 18 + packages/apps/package.json | 3 + yarn.lock | 30 + 76 files changed, 8036 insertions(+) create mode 100644 packages/apps/base-runtime/src/AppObjectRegistry.ts create mode 100644 packages/apps/base-runtime/src/handlers/api-handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/construct.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleGetStatus.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleInitialize.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnDisable.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnEnable.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnInstall.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnPreSettingUpdate.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnSettingUpdated.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnUninstall.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleOnUpdate.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleSetStatus.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handleUploadEvents.ts create mode 100644 packages/apps/base-runtime/src/handlers/app/handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/lib/assertions.ts create mode 100644 packages/apps/base-runtime/src/handlers/listener/handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/outboundcomms-handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/scheduler-handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/slashcommand-handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/api-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/listener-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/scheduler-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/slashcommand-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/uikit-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/upload-event-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/tests/videoconference-handler.test.ts create mode 100644 packages/apps/base-runtime/src/handlers/uikit/handler.ts create mode 100644 packages/apps/base-runtime/src/handlers/videoconference-handler.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/BlockBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/DiscussionBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/LivechatMessageBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/MessageBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/RoomBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/UserBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/builders/VideoConferenceBuilder.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/extenders/HttpExtender.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/extenders/MessageExtender.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/extenders/RoomExtender.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/extenders/VideoConferenceExtend.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/formatResponseErrorHandler.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/http.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/mod.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/modify/ModifyExtender.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/notifier.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/ModifyExtender.test.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/formatResponseErrorHandler.test.ts create mode 100644 packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts create mode 100644 packages/apps/base-runtime/src/lib/ast/mod.ts create mode 100644 packages/apps/base-runtime/src/lib/ast/operations.ts create mode 100644 packages/apps/base-runtime/src/lib/ast/tests/data/ast_blocks.ts create mode 100644 packages/apps/base-runtime/src/lib/ast/tests/operations.test.ts create mode 100644 packages/apps/base-runtime/src/lib/codec.ts create mode 100644 packages/apps/base-runtime/src/lib/logger.ts create mode 100644 packages/apps/base-runtime/src/lib/messenger.ts create mode 100644 packages/apps/base-runtime/src/lib/metricsCollector.ts create mode 100644 packages/apps/base-runtime/src/lib/parseArgs.ts create mode 100644 packages/apps/base-runtime/src/lib/requestContext.ts create mode 100644 packages/apps/base-runtime/src/lib/room.ts create mode 100644 packages/apps/base-runtime/src/lib/roomFactory.ts create mode 100644 packages/apps/base-runtime/src/lib/sanitizeDeprecatedUsage.ts create mode 100644 packages/apps/base-runtime/src/lib/secureFields.ts create mode 100644 packages/apps/base-runtime/src/lib/tests/logger.test.ts create mode 100644 packages/apps/base-runtime/src/lib/tests/messenger.test.ts create mode 100644 packages/apps/base-runtime/src/lib/tests/secureFields.test.ts create mode 100644 packages/apps/base-runtime/src/lib/wrapAppForRequest.ts create mode 100644 packages/apps/base-runtime/src/mainLoop.ts create mode 100644 packages/apps/base-runtime/tsconfig.json diff --git a/packages/apps/base-runtime/src/AppObjectRegistry.ts b/packages/apps/base-runtime/src/AppObjectRegistry.ts new file mode 100644 index 0000000000000..fd7c8adb23f68 --- /dev/null +++ b/packages/apps/base-runtime/src/AppObjectRegistry.ts @@ -0,0 +1,25 @@ +export type Maybe = T | null | undefined; + +export const AppObjectRegistry = new (class { + registry: Record = {}; + + public get(key: string): Maybe { + return this.registry[key] as Maybe; + } + + public set(key: string, value: unknown): void { + this.registry[key] = value; + } + + public has(key: string): boolean { + return key in this.registry; + } + + public delete(key: string): void { + delete this.registry[key]; + } + + public clear(): void { + this.registry = {}; + } +})(); diff --git a/packages/apps/base-runtime/src/handlers/api-handler.ts b/packages/apps/base-runtime/src/handlers/api-handler.ts new file mode 100644 index 0000000000000..2c471b4d0e583 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/api-handler.ts @@ -0,0 +1,51 @@ +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; +import { AppAccessorsInstance } from '../lib/accessors/mod'; +import type { RequestContext } from '../lib/requestContext'; +import { wrapComposedApp } from '../lib/wrapAppForRequest'; + +export default async function apiHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const [, /* always "api" */ ...parts] = call.split(':'); + const httpMethod = parts.pop(); + const path = parts.join(':'); + + const endpoint = AppObjectRegistry.get(`api:${path}`); + const { logger } = request.context; + + if (!endpoint) { + return new JsonRpcError(`Endpoint ${path} not found`, -32000); + } + + const method = endpoint[httpMethod as keyof IApiEndpoint]; + + if (typeof method !== 'function') { + return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000); + } + + const [requestData, endpointInfo] = params as Array; + + logger.debug(`${path}'s ${call} is being executed...`, requestData); + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const result = await (method as Function).apply(wrapComposedApp(endpoint, request), [ + requestData, + endpointInfo, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger.debug(`${path}'s ${call} was successfully executed.`); + + return result; + } catch (e) { + logger.debug(`${path}'s ${call} was unsuccessful.`); + return new JsonRpcError(e.message || 'Internal server error', -32000); + } +} diff --git a/packages/apps/base-runtime/src/handlers/app/construct.ts b/packages/apps/base-runtime/src/handlers/app/construct.ts new file mode 100644 index 0000000000000..f024f15f70e1c --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/construct.ts @@ -0,0 +1,130 @@ +import type { IParseAppPackageResult } from '@rocket.chat/apps/dist/server/compiler/IParseAppPackageResult'; +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage'; + +/** + * A platform-dependent `require` used to resolve the modules an app is allowed + * to load (native `node:` modules, a small allow-list of npm packages, and + * apps-engine files). Each runtime injects its own via {@link setSandboxRequire} + * — Node hands over its global `require`, Deno hands over its `createRequire` + * shim that knows how to resolve compiled apps-engine paths. + */ +type SandboxRequire = (module: string) => unknown; + +function defaultSandboxRequire(): never { + throw new Error('No sandbox require has been injected; the runtime adapter must call setSandboxRequire() during bootstrap'); +} + +let sandboxRequire: SandboxRequire = defaultSandboxRequire; + +export function setSandboxRequire(newRequire: SandboxRequire): void { + sandboxRequire = newRequire; +} + +/** + * Extra globals bound into the app's eval shell on top of the common ones + * (`exports`, `module`, `require`, `console`, `globalThis`). Node needs none; + * Deno injects a `Buffer` and shadows `Deno` with `undefined`. Injecting them + * as data keeps the eval-shell skeleton single-source. + */ +type SandboxGlobals = Record; + +let sandboxGlobals: SandboxGlobals = {}; + +export function setSandboxGlobals(globals: SandboxGlobals): void { + sandboxGlobals = globals; +} + +function wrapAppCode(code: string): (require: SandboxRequire) => Promise> { + const globals = sandboxGlobals; + // The common globals are bound by name; any platform-specific extras are + // spread in by name from the injected `sandboxGlobals`, so the shell + // skeleton stays identical across runtimes. + const extraNames = Object.keys(globals); + const extraParams = extraNames.length ? `,${extraNames.join(',')}` : ''; + const extraArgs = extraNames.map((name) => `,__globals[${JSON.stringify(name)}]`).join(''); + + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- This is the reason we run in a separate process + const fn = new Function( + 'require', + '__globals', + ` + const exports = {}; + const module = { exports }; + const _error = console.error.bind(console); + const _console = { + log: _error, + error: _error, + debug: _error, + info: _error, + warn: _error, + }; + + const result = (async (exports,module,require,console,globalThis${extraParams}) => { + ${code}; + })(exports,module,require,_console,undefined${extraArgs}); + + return result.then(() => module.exports);`, + ) as (require: SandboxRequire, globals: SandboxGlobals) => Promise>; + + return (require: SandboxRequire) => fn(require, globals); +} + +type AppConstructor = new (...args: unknown[]) => App; + +export default async function handleConstructApp(request: RequestContext): Promise { + const { params } = request; + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [appPackage] = params as [IParseAppPackageResult]; + + if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + AppObjectRegistry.set('id', appPackage.info.id); + const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]); + + const exports = await wrapAppCode(source)(sandboxRequire); + + // This is the same naive logic we've been using in the App Compiler + // Applying the correct type here is quite difficult because of the dynamic nature of the code + const appClass = Object.values(exports)[0] as AppConstructor; + + const app = new appClass(appPackage.info, request.context.logger, AppAccessorsInstance.getDefaultAppAccessors()); + + if (typeof app.getName !== 'function') { + throw new Error('App must contain a getName function'); + } + + if (typeof app.getNameSlug !== 'function') { + throw new Error('App must contain a getNameSlug function'); + } + + if (typeof app.getVersion !== 'function') { + throw new Error('App must contain a getVersion function'); + } + + if (typeof app.getID !== 'function') { + throw new Error('App must contain a getID function'); + } + + if (typeof app.getDescription !== 'function') { + throw new Error('App must contain a getDescription function'); + } + + if (typeof app.getRequiredApiVersion !== 'function') { + throw new Error('App must contain a getRequiredApiVersion function'); + } + + AppObjectRegistry.set('app', app); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleGetStatus.ts b/packages/apps/base-runtime/src/handlers/app/handleGetStatus.ts new file mode 100644 index 0000000000000..402285f6c743b --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleGetStatus.ts @@ -0,0 +1,16 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; + +export default function handleGetStatus(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.getStatus !== 'function') { + throw new Error('App must contain a getStatus function', { + cause: 'invalid_app', + }); + } + + return app.getStatus(); +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleInitialize.ts b/packages/apps/base-runtime/src/handlers/app/handleInitialize.ts new file mode 100644 index 0000000000000..ae09f1a442608 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleInitialize.ts @@ -0,0 +1,24 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleInitialize(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.initialize !== 'function') { + throw new Error('App must contain an initialize function', { + cause: 'invalid_app', + }); + } + + await app.initialize.call( + wrapAppForRequest(app, request), + AppAccessorsInstance.getConfigurationExtend(), + AppAccessorsInstance.getEnvironmentRead(), + ); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnDisable.ts b/packages/apps/base-runtime/src/handlers/app/handleOnDisable.ts new file mode 100644 index 0000000000000..57c4365212866 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnDisable.ts @@ -0,0 +1,20 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleOnDisable(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onDisable !== 'function') { + throw new Error('App must contain an onDisable function', { + cause: 'invalid_app', + }); + } + + await app.onDisable.call(wrapAppForRequest(app, request), AppAccessorsInstance.getConfigurationModify()); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnEnable.ts b/packages/apps/base-runtime/src/handlers/app/handleOnEnable.ts new file mode 100644 index 0000000000000..0e9f42d16ba9b --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnEnable.ts @@ -0,0 +1,22 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default function handleOnEnable(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onEnable !== 'function') { + throw new Error('App must contain an onEnable function', { + cause: 'invalid_app', + }); + } + + return app.onEnable.call( + wrapAppForRequest(app, request), + AppAccessorsInstance.getEnvironmentRead(), + AppAccessorsInstance.getConfigurationModify(), + ); +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnInstall.ts b/packages/apps/base-runtime/src/handlers/app/handleOnInstall.ts new file mode 100644 index 0000000000000..1ec8d91a58769 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnInstall.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleOnInstall(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onInstall !== 'function') { + throw new Error('App must contain an onInstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onInstall.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnPreSettingUpdate.ts b/packages/apps/base-runtime/src/handlers/app/handleOnPreSettingUpdate.ts new file mode 100644 index 0000000000000..8ce303c8aed13 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnPreSettingUpdate.ts @@ -0,0 +1,31 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default function handleOnPreSettingUpdate(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onPreSettingUpdate !== 'function') { + throw new Error('App must contain an onPreSettingUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + return app.onPreSettingUpdate.call( + wrapAppForRequest(app, request), + setting, + AppAccessorsInstance.getConfigurationModify(), + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + ); +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnSettingUpdated.ts b/packages/apps/base-runtime/src/handlers/app/handleOnSettingUpdated.ts new file mode 100644 index 0000000000000..caed4617c1920 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnSettingUpdated.ts @@ -0,0 +1,33 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleOnSettingUpdated(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onSettingUpdated !== 'function') { + throw new Error('App must contain an onSettingUpdated function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + await app.onSettingUpdated.call( + wrapAppForRequest(app, request), + setting, + AppAccessorsInstance.getConfigurationModify(), + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + ); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnUninstall.ts b/packages/apps/base-runtime/src/handlers/app/handleOnUninstall.ts new file mode 100644 index 0000000000000..158eaa8ac31ec --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnUninstall.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleOnUninstall(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUninstall !== 'function') { + throw new Error('App must contain an onUninstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUninstall.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleOnUpdate.ts b/packages/apps/base-runtime/src/handlers/app/handleOnUpdate.ts new file mode 100644 index 0000000000000..06d80245ed3b5 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleOnUpdate.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleOnUpdate(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUpdate !== 'function') { + throw new Error('App must contain an onUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUpdate.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleSetStatus.ts b/packages/apps/base-runtime/src/handlers/app/handleSetStatus.ts new file mode 100644 index 0000000000000..856ee756599b8 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleSetStatus.ts @@ -0,0 +1,28 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleSetStatus(request: RequestContext): Promise { + const { params } = request; + + if (!Array.isArray(params) || !Object.values(AppStatus).includes(params[0])) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [status] = params as [typeof AppStatus]; + + const app = AppObjectRegistry.get('app'); + + if (!app || typeof (app as unknown as Record).setStatus !== 'function') { + throw new Error('App must contain a setStatus function', { + cause: 'invalid_app', + }); + } + + await (app as unknown as { setStatus(status: typeof AppStatus): Promise }).setStatus.call(wrapAppForRequest(app, request), status); + + return null; +} diff --git a/packages/apps/base-runtime/src/handlers/app/handleUploadEvents.ts b/packages/apps/base-runtime/src/handlers/app/handleUploadEvents.ts new file mode 100644 index 0000000000000..4cd6bb69fec24 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handleUploadEvents.ts @@ -0,0 +1,81 @@ +import { Buffer } from 'node:buffer'; +import { readFile } from 'node:fs/promises'; + +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException'; +import type { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; +import { assertAppAvailable, assertHandlerFunction, isPlainObject } from '../lib/assertions'; + +export const uploadEvents = ['executePreFileUpload'] as const; + +function assertIsUpload(v: unknown): asserts v is IUploadDetails { + if (isPlainObject(v) && !!v.rid && (!!v.userId || !!v.visitorToken)) return; + + throw JsonRpcError.invalidParams({ err: `Invalid 'file' parameter. Expected IUploadDetails, got`, value: v }); +} + +function assertString(v: unknown): asserts v is string { + if (v && typeof v === 'string') return; + + throw JsonRpcError.invalidParams({ err: `Invalid 'path' parameter. Expected string, got`, value: v }); +} + +export default async function handleUploadEvents(request: RequestContext): Promise { + const { method: rawMethod, params } = request as { + method: `app:${(typeof uploadEvents)[number]}`; + params: [{ file?: IUploadDetails; path?: string }]; + }; + const [, method] = rawMethod.split(':') as ['app', (typeof uploadEvents)[number]]; + + try { + const [{ file, path }] = params; + + const app = AppObjectRegistry.get('app'); + const handlerFunction = app?.[method as keyof App] as unknown; + + assertAppAvailable(app); + assertHandlerFunction(handlerFunction); + assertIsUpload(file); + assertString(path); + + let context: IFileUploadContext; + + switch (method) { + case 'executePreFileUpload': { + const fileContents = await readFile(path); + context = { file, content: Buffer.from(fileContents) }; + break; + } + } + + return await handlerFunction.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch (e) { + if (e?.name === AppsEngineException.name) { + return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); + } + + if (e instanceof JsonRpcError) { + return e; + } + + return JsonRpcError.internalError({ + err: e.message, + ...(e.code && { code: e.code }), + }); + } +} diff --git a/packages/apps/base-runtime/src/handlers/app/handler.ts b/packages/apps/base-runtime/src/handlers/app/handler.ts new file mode 100644 index 0000000000000..991d2e3a6b5b4 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/app/handler.ts @@ -0,0 +1,116 @@ +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import handleConstructApp from './construct'; +import handleGetStatus from './handleGetStatus'; +import handleInitialize from './handleInitialize'; +import handleOnDisable from './handleOnDisable'; +import handleOnEnable from './handleOnEnable'; +import handleOnInstall from './handleOnInstall'; +import handleOnPreSettingUpdate from './handleOnPreSettingUpdate'; +import handleOnSettingUpdated from './handleOnSettingUpdated'; +import handleOnUninstall from './handleOnUninstall'; +import handleOnUpdate from './handleOnUpdate'; +import handleSetStatus from './handleSetStatus'; +import handleUploadEvents, { uploadEvents } from './handleUploadEvents'; +import type { RequestContext } from '../../lib/requestContext'; +import { isOneOf } from '../lib/assertions'; +import handleListener from '../listener/handler'; +import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler'; + +export default async function handleApp(request: RequestContext): Promise { + const { method } = request; + const { logger } = request.context; + const [, appMethod] = method.split(':'); + + try { + // We don't want the getStatus method to generate logs, so we handle it separately + if (appMethod === 'getStatus') { + return await handleGetStatus(); + } + + logger.debug({ msg: `A method is being called...`, appMethod }); + + const formatResult = (result: Defined | JsonRpcError): Defined | JsonRpcError => { + if (result instanceof JsonRpcError) { + logger.debug({ + msg: `'${appMethod}' was unsuccessful.`, + appMethod, + err: result, + errorMessage: result.message, + }); + } else { + logger.debug({ + msg: `'${appMethod}' was successfully called! The result is:`, + appMethod, + result, + }); + } + + return result; + }; + + let result: Promise | undefined = undefined; + + if (isOneOf(appMethod, uploadEvents)) { + result = handleUploadEvents(request); + } else if (isOneOf(appMethod, uikitInteractions)) { + result = handleUIKitInteraction(request); + } else if (appMethod.startsWith('check') || appMethod.startsWith('execute')) { + result = handleListener(request); + } + + switch (appMethod) { + case 'construct': + result = handleConstructApp(request); + break; + case 'initialize': + result = handleInitialize(request); + break; + case 'setStatus': + result = handleSetStatus(request); + break; + case 'onEnable': + result = handleOnEnable(request); + break; + case 'onDisable': + result = handleOnDisable(request); + break; + case 'onInstall': + result = handleOnInstall(request); + break; + case 'onUninstall': + result = handleOnUninstall(request); + break; + case 'onPreSettingUpdate': + result = handleOnPreSettingUpdate(request); + break; + case 'onSettingUpdated': + result = handleOnSettingUpdated(request); + break; + case 'onUpdate': + result = handleOnUpdate(request); + break; + } + + if (typeof result === 'undefined') { + throw new JsonRpcError(`Unknown method "${appMethod}"`, -32601); + } + + return await result.then(formatResult); + } catch (e: unknown) { + if (!(e instanceof Error)) { + return new JsonRpcError('Unknown error', -32000, e); + } + + if ((e.cause as string)?.includes('invalid_param_type')) { + return JsonRpcError.invalidParams(null); + } + + if ((e.cause as string)?.includes('invalid_app')) { + return JsonRpcError.internalError({ message: 'App unavailable' }); + } + + return new JsonRpcError(e.message, -32000, e); + } +} diff --git a/packages/apps/base-runtime/src/handlers/lib/assertions.ts b/packages/apps/base-runtime/src/handlers/lib/assertions.ts new file mode 100644 index 0000000000000..54879b06614ca --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/lib/assertions.ts @@ -0,0 +1,51 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import { JsonRpcError } from 'jsonrpc-lite'; + +/** + * Known failures that can happen in the runtime. + * + * DRT = Deno RunTime + */ +export const Errors = { + DRT_APP_NOT_AVAILABLE: 'DRT_APP_NOT_AVAILABLE', + DRT_EVENT_HANDLER_FUNCTION_MISSING: 'DRT_EVENT_HANDLER_FUNCTION_MISSING', +}; + +export function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +export function isPlainObject(v: unknown): v is Record { + if (!isRecord(v)) { + return false; + } + + const prototype = Object.getPrototypeOf(v); + + return prototype === null || prototype.constructor === Object; +} + +/** + * Type guard function to check if a value is included in a readonly array + * and narrow its type accordingly. + */ +export function isOneOf(value: unknown, array: readonly T[]): value is T { + return array.includes(value as T); +} + +export function isApp(v: unknown): v is App { + return !!v && typeof (v as unknown as Record).extendConfiguration === 'function'; +} + +export function assertAppAvailable(v: unknown): asserts v is App { + if (isApp(v)) return; + + throw JsonRpcError.internalError({ err: 'App object not available', code: Errors.DRT_APP_NOT_AVAILABLE }); +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function assertHandlerFunction(v: unknown): asserts v is Function { + if (v instanceof Function) return; + + throw JsonRpcError.internalError({ err: `Expected handler function, got ${v}`, code: Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING }); +} diff --git a/packages/apps/base-runtime/src/handlers/listener/handler.ts b/packages/apps/base-runtime/src/handlers/listener/handler.ts new file mode 100644 index 0000000000000..d94619254930d --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/listener/handler.ts @@ -0,0 +1,153 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender'; +import type { AppAccessors } from '../../lib/accessors/mod'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { Room } from '../../lib/room'; +import createRoom from '../../lib/roomFactory'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; + +export default async function handleListener(request: RequestContext): Promise { + const { method, params } = request; + const [, evtInterface] = method.split(':'); + const app = AppObjectRegistry.get('app'); + + const eventExecutor = app?.[evtInterface as keyof App]; + + if (!app || typeof eventExecutor !== 'function') { + return JsonRpcError.methodNotFound({ + message: 'Invalid event interface called on app', + }); + } + + if (!Array.isArray(params) || params.length < 1 || params.length > 2) { + return JsonRpcError.invalidParams(null); + } + + try { + const args = parseArgs({ AppAccessorsInstance }, evtInterface, params); + return await (eventExecutor as (...args: unknown[]) => Promise).apply(wrapAppForRequest(app, request), args); + } catch (e) { + if (e instanceof JsonRpcError) { + return e; + } + + if (e instanceof AppsEngineException) { + return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); + } + + return JsonRpcError.internalError({ message: e.message }); + } +} + +export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] { + const { AppAccessorsInstance } = deps; + /** + * param1 is the context for the event handler execution + * param2 is an optional extra content that some hanlers require + */ + const [param1, param2] = params as [unknown, unknown]; + + if (!param1) { + throw JsonRpcError.invalidParams(null); + } + + let context = param1; + + if (evtMethod.includes('Message')) { + context = hydrateMessageObjects(context) as Record; + } else if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) { + (context as Record).room = createRoom( + (context as Record).room as IRoom, + AppAccessorsInstance.getSenderFn(), + ); + } else if (evtMethod.includes('PreRoom')) { + context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn()); + } + + const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()]; + + // "check" events will only go this far - (context, reader, http) + if (evtMethod.startsWith('check')) { + // "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext) + if (param2) { + args.push(hydrateMessageObjects(param2)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence) injected + args.push(AppAccessorsInstance.getPersistence()); + + // "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence) + if (evtMethod.endsWith('Extend')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageExtender(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomExtender(param1 as IRoom)); + } + + return args; + } + + // "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence) + if (evtMethod.endsWith('Modify')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageBuilder(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomBuilder(param1 as IRoom)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence, modifier) injected + args.push(AppAccessorsInstance.getModifier()); + + // This guy gets an extra one + if (evtMethod === 'executePostMessageDeleted') { + if (!param2) { + throw JsonRpcError.invalidParams(null); + } + + args.push(hydrateMessageObjects(param2)); + } + + return args; +} + +/** + * Hydrate the context object with the correct IMessage + * + * Some information is lost upon serializing the data from listeners through the pipes, + * so here we hydrate the complete object as necessary + */ +function hydrateMessageObjects(context: unknown): unknown { + if (objectIsRawMessage(context)) { + context.room = createRoom(context.room as IRoom, AppAccessorsInstance.getSenderFn()) as unknown as IRoom; + } else if ((context as Record)?.message) { + (context as Record).message = hydrateMessageObjects((context as Record).message); + } + + return context; +} + +function objectIsRawMessage(value: unknown): value is IMessage { + if (!value) return false; + + const { id, room, sender, createdAt } = value as Record; + + // Check if we have the fields of a message and the room hasn't already been hydrated + return !!(id && room && sender && createdAt) && !(room instanceof Room); +} diff --git a/packages/apps/base-runtime/src/handlers/outboundcomms-handler.ts b/packages/apps/base-runtime/src/handlers/outboundcomms-handler.ts new file mode 100644 index 0000000000000..da78a8b2a5784 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/outboundcomms-handler.ts @@ -0,0 +1,38 @@ +import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; +import { AppAccessorsInstance } from '../lib/accessors/mod'; +import type { RequestContext } from '../lib/requestContext'; +import { wrapComposedApp } from '../lib/wrapAppForRequest'; + +export default async function outboundMessageHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`outboundCommunication:${providerName}`); + + if (!provider) { + return new JsonRpcError('error-invalid-provider', -32000); + } + + const method = provider[methodName as keyof IOutboundMessageProviders]; + const { logger } = request.context; + const args = (params as Array) ?? []; + + try { + logger.debug(`Executing ${methodName} on outbound communication provider...`); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-function-type + return await (method as Function).apply(wrapComposedApp(provider, request), [ + ...args, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + } catch (e) { + return new JsonRpcError(e.message, -32000); + } +} diff --git a/packages/apps/base-runtime/src/handlers/scheduler-handler.ts b/packages/apps/base-runtime/src/handlers/scheduler-handler.ts new file mode 100644 index 0000000000000..a7730c8728806 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/scheduler-handler.ts @@ -0,0 +1,66 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; +import { AppAccessorsInstance } from '../lib/accessors/mod'; +import type { RequestContext } from '../lib/requestContext'; +import { wrapAppForRequest } from '../lib/wrapAppForRequest'; +import { assertAppAvailable } from './lib/assertions'; + +export default async function handleScheduler(request: RequestContext): Promise { + const { method, params } = request; + const { logger } = request.context; + + const [, processorId] = method.split(':'); + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams({ message: 'Invalid params' }); + } + + const [context] = params as [Record]; + + // AppSchedulerManager will append the appId to the processor name to avoid conflicts + const processor = AppObjectRegistry.get(`scheduler:${processorId}`); + + if (!processor) { + return JsonRpcError.methodNotFound({ + message: `Could not find processor for method ${method}`, + }); + } + + logger.debug({ msg: 'Job processor is being executed...', processorId: processor.id }); + + const app = AppObjectRegistry.get('app'); + + try { + assertAppAvailable(app); + + await processor.processor.call( + // Processor registration doesn't require the App dev to instantiate a class passing + // a reference to an App object, so we don't have a good way of hijacking the Logger + // we need. + // The only way we have to provide a durable Logger instance for the processor is by + // binding its execution to the proxied App reference itself. Unfortunately, the API + // ends up being opaque, but there isn't much we can do for now. + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ); + + logger.debug({ msg: 'Job processor was successfully executed', processorId: processor.id }); + + return null; + } catch (err) { + logger.error({ err, msg: 'Job processor was unsuccessful', processorId: processor.id }); + + if (err instanceof JsonRpcError) { + return err; + } + + return JsonRpcError.internalError({ message: err.message }); + } +} diff --git a/packages/apps/base-runtime/src/handlers/slashcommand-handler.ts b/packages/apps/base-runtime/src/handlers/slashcommand-handler.ts new file mode 100644 index 0000000000000..3dbba0c07caf5 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/slashcommand-handler.ts @@ -0,0 +1,123 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; +import type { AppAccessors } from '../lib/accessors/mod'; +import { AppAccessorsInstance } from '../lib/accessors/mod'; +import type { RequestContext } from '../lib/requestContext'; +import createRoom from '../lib/roomFactory'; +import { wrapComposedApp } from '../lib/wrapAppForRequest'; + +export default async function slashCommandHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const { logger } = request.context; + + const [, commandName, method] = call.split(':'); + + const command = AppObjectRegistry.get(`slashcommand:${commandName}`); + + if (!command) { + return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000); + } + + let result: Awaited>; + + logger.debug({ msg: `Command is being executed...`, commandName, method, params }); + + try { + if (method === 'executor' || method === 'previewer') { + result = await handleExecutor({ AppAccessorsInstance, request }, command, method, params); + } else if (method === 'executePreviewItem') { + result = await handlePreviewItem({ AppAccessorsInstance, request }, command, params); + } else { + return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); + } + + logger.debug({ msg: `Command was successfully executed.`, commandName, method }); + } catch (error) { + logger.debug({ msg: `Command was unsuccessful.`, commandName, method, err: error }); + + return new JsonRpcError(error.message, -32000); + } + + return result; +} + +type Deps = { + AppAccessorsInstance: AppAccessors; + request: RequestContext; +}; + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param method The method that is being executed + * @param params The parameters that are being passed to the method + */ +export function handleExecutor(deps: Deps, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) { + const executor = command[method]; + + if (typeof executor !== 'function') { + throw new Error(`Method ${method} not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const { sender, room, params: args, threadId, triggerId } = params[0] as Record; + + const context = new SlashCommandContext( + sender as SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()) as unknown as IRoom, + args as SlashCommandContext['params'], + threadId as SlashCommandContext['threadId'], + triggerId as SlashCommandContext['triggerId'], + ); + + return executor.apply(wrapComposedApp(command, deps.request), [ + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ]); +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param params The parameters that are being passed to the method + */ +export function handlePreviewItem(deps: Deps, command: ISlashCommand, params: unknown) { + if (typeof command.executePreviewItem !== 'function') { + throw new Error(`Method not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record, Record]; + + const context = new SlashCommandContext( + sender as SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()) as unknown as IRoom, + args as SlashCommandContext['params'], + threadId as SlashCommandContext['threadId'], + triggerId as SlashCommandContext['triggerId'], + ); + + return command.executePreviewItem.call( + wrapComposedApp(command, deps.request), + previewItem, + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ); +} diff --git a/packages/apps/base-runtime/src/handlers/tests/api-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/api-handler.test.ts new file mode 100644 index 0000000000000..0c9a92ed1f67a --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/api-handler.test.ts @@ -0,0 +1,176 @@ +import * as assert from 'node:assert'; +import { beforeEach, describe, it, mock } from 'node:test'; + +import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiRequest, IApiEndpointInfo, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import apiHandler from '../api-handler'; +import { createMockRequest } from './helpers/mod'; + +describe('handlers > api', () => { + const mockEndpoint: Required> = { + path: '/test', + examples: {}, + authRequired: false, + _availableMethods: [], + get( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + return Promise.resolve({ status: 200 }); + }, + post( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + return Promise.resolve({ status: 200 }); + }, + put( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + throw new Error('Method execution error example'); + }, + head( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + throw new Error('Function not implemented.'); + }, + options( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + throw new Error('Function not implemented.'); + }, + patch( + _request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + throw new Error('Function not implemented.'); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('api:/test', mockEndpoint); + }); + + it('correctly handles execution of an api endpoint method GET', async () => { + const _spy = mock.method(mockEndpoint, 'get'); + + const result = await apiHandler(createMockRequest({ method: 'api:/test:get', params: ['request', 'endpointInfo'] })); + + assert.deepStrictEqual(result, { status: 200 }); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo'); + + _spy.mock.restore(); + }); + + it('correctly handles execution of an api endpoint method POST', async () => { + const _spy = mock.method(mockEndpoint, 'post'); + + const result = await apiHandler(createMockRequest({ method: 'api:/test:post', params: ['request', 'endpointInfo'] })); + + assert.deepStrictEqual(result, { status: 200 }); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo'); + + _spy.mock.restore(); + }); + + it('correctly handles an error if the method not exists for the selected endpoint', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/test:delete`, params: ['request', 'endpointInfo'] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual(result.message, `/test's delete not exists`); + assert.strictEqual(result.code, -32000); + }); + + it('correctly handles an error if endpoint not exists', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/error:get`, params: ['request', 'endpointInfo'] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual((result as any).message, `Endpoint /error not found`); + assert.strictEqual((result as any).code, -32000); + }); + + it('correctly handles an error if the method execution fails', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/test:put`, params: ['request', 'endpointInfo'] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual((result as any).message, `Method execution error example`); + assert.strictEqual((result as any).code, -32000); + }); + + it('correctly handles dynamic paths with parameters (e.g., webhook/:event)', async () => { + const mockDynamicEndpoint = { + ...mockEndpoint, + path: 'webhook/:event', + post: (_request: any, _endpoint: any, _read: any, _modify: any, _http: any, _persis: any) => + Promise.resolve('webhook handled' as any), + }; + + AppObjectRegistry.set('api:webhook/:event', mockDynamicEndpoint); + + const _spy = mock.method(mockDynamicEndpoint, 'post'); + + const result = await apiHandler(createMockRequest({ method: 'api:webhook/:event:post', params: ['request', 'endpointInfo'] })); + + assert.deepStrictEqual(result, 'webhook handled'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo'); + + _spy.mock.restore(); + }); + + it('correctly handles paths with multiple segments and colons', async () => { + const mockComplexEndpoint = { + ...mockEndpoint, + path: 'api/v1/:resource/:id', + get: (_request: any, _endpoint: any, _read: any, _modify: any, _http: any, _persis: any) => Promise.resolve({ status: 201 }), + }; + + AppObjectRegistry.set('api:api/v1/:resource/:id', mockComplexEndpoint); + + const _spy = mock.method(mockComplexEndpoint, 'get'); + + const result = await apiHandler(createMockRequest({ method: 'api:api/v1/:resource/:id:get', params: ['request', 'endpointInfo'] })); + + assert.deepStrictEqual(result, { status: 201 }); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6); + + _spy.mock.restore(); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts b/packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts new file mode 100644 index 0000000000000..739c944359484 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts @@ -0,0 +1,29 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { Logger } from '../../../lib/logger'; +import type { RequestDescriptor } from '../../../lib/messenger'; +import type { RequestContext } from '../../../lib/requestContext'; + +export function createMockRequest({ method, params }: RequestDescriptor): RequestContext { + return { + jsonrpc: '2.0', + id: 1, + method, + params, + context: { + logger: new Logger(method), + }, + serialize: () => '', + }; +} + +export function createMockApp(): App { + return { + extendConfiguration: () => {}, + getID: () => 'mockApp', + getLogger: () => ({ + debug: () => {}, + error: () => {}, + }), + } as unknown as App; +} diff --git a/packages/apps/base-runtime/src/handlers/tests/listener-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/listener-handler.test.ts new file mode 100644 index 0000000000000..e277ab56d8803 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/listener-handler.test.ts @@ -0,0 +1,225 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender'; +import type { AppAccessors } from '../../lib/accessors/mod'; +import { Room } from '../../lib/room'; +import { parseArgs } from '../listener/handler'; + +describe('handlers > listeners', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => { + const evtMethod = 'checkPreMessageSentPrevent'; + // For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 3); + assert.deepStrictEqual(params[0], { __type: 'context' }); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => { + const evtMethod = 'checkPostMessageDeleted'; + // For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario, + // and the extraContext will provide further information such the user who deleted the message + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 4); + assert.deepStrictEqual(params[0], { __type: 'context' }); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + assert.deepStrictEqual(params[3], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => { + const evtMethod = 'checkPreRoomCreateExtend'; + // For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [ + { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 3); + + assert.ok(params[0] instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => { + const evtMethod = 'executePreMessageSentExtend'; + // For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + // Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals + assert.strictEqual((params[0] as any).__type, 'context'); + assert.ok(params[1] instanceof MessageExtender, `Expected instance of ${MessageExtender.name}`); + assert.deepStrictEqual(params[2], { __type: 'reader' }); + assert.deepStrictEqual(params[3], { __type: 'http' }); + assert.deepStrictEqual(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => { + const evtMethod = 'executePreRoomCreateExtend'; + // For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + // Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals + assert.strictEqual((params[0] as any).__type, 'context'); + assert.ok(params[1] instanceof RoomExtender, `Expected instance of ${RoomExtender.name}`); + assert.deepStrictEqual(params[2], { __type: 'reader' }); + assert.deepStrictEqual(params[3], { __type: 'http' }); + assert.deepStrictEqual(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => { + const evtMethod = 'executePreMessageSentModify'; + // For the 'executePreMessageSentModify' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + // Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals + assert.strictEqual((params[0] as any).__type, 'context'); + assert.ok(params[1] instanceof MessageBuilder, `Expected instance of ${MessageBuilder.name}`); + assert.deepStrictEqual(params[2], { __type: 'reader' }); + assert.deepStrictEqual(params[3], { __type: 'http' }); + assert.deepStrictEqual(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => { + const evtMethod = 'executePreRoomCreateModify'; + // For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + // Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals + assert.strictEqual((params[0] as any).__type, 'context'); + assert.ok(params[1] instanceof RoomBuilder, `Expected instance of ${RoomBuilder.name}`); + assert.deepStrictEqual(params[2], { __type: 'reader' }); + assert.deepStrictEqual(params[3], { __type: 'http' }); + assert.deepStrictEqual(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => { + const evtMethod = 'executePostRoomUserJoined'; + // For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + assert.deepStrictEqual(params[3], { __type: 'persistence' }); + assert.deepStrictEqual(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => { + const evtMethod = 'executePostRoomUserLeave'; + // For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + assert.deepStrictEqual(params[3], { __type: 'persistence' }); + assert.deepStrictEqual(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => { + const evtMethod = 'executePostMessageDeleted'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 6); + assert.deepStrictEqual(params[0], { __type: 'context' }); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + assert.deepStrictEqual(params[3], { __type: 'persistence' }); + assert.deepStrictEqual(params[4], { __type: 'modifier' }); + assert.deepStrictEqual(params[5], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageSent" method', () => { + const evtMethod = 'executePostMessageSent'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [ + { + id: 'fake', + sender: 'fake', + createdAt: Date.now(), + room: { + id: 'fake-room', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assert.deepStrictEqual(params.length, 5); + assert.strictEqual((params[0] as any).id, 'fake'); + assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(params[1], { __type: 'reader' }); + assert.deepStrictEqual(params[2], { __type: 'http' }); + assert.deepStrictEqual(params[3], { __type: 'persistence' }); + assert.deepStrictEqual(params[4], { __type: 'modifier' }); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/scheduler-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/scheduler-handler.test.ts new file mode 100644 index 0000000000000..ed0d1a7164186 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/scheduler-handler.test.ts @@ -0,0 +1,41 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it } from 'node:test'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessors } from '../../lib/accessors/mod'; +import handleScheduler from '../scheduler-handler'; +import { createMockApp, createMockRequest } from './helpers/mod'; + +describe('handlers > scheduler', () => { + const mockAppAccessors = new AppAccessors(() => + Promise.resolve({ + id: 'mockId', + result: {}, + jsonrpc: '2.0', + serialize: () => '', + }), + ); + + const mockApp = createMockApp(); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('app', mockApp); + mockAppAccessors.getConfigurationExtend().scheduler.registerProcessors([ + { + id: 'mockId', + processor: () => Promise.resolve(), + }, + ]); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('correctly executes a request to a processor', async () => { + const result = await handleScheduler(createMockRequest({ method: 'scheduler:mockId', params: [{}] })); + + assert.deepStrictEqual(result, null); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/slashcommand-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/slashcommand-handler.test.ts new file mode 100644 index 0000000000000..57a737487dcb7 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/slashcommand-handler.test.ts @@ -0,0 +1,162 @@ +import * as assert from 'node:assert'; +import { beforeEach, describe, it, mock } from 'node:test'; + +import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISlashCommand, SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { createMockRequest } from './helpers/mod'; +import type { AppAccessors } from '../../lib/accessors/mod'; +import { Room } from '../../lib/room'; +import { handleExecutor, handlePreviewItem } from '../slashcommand-handler'; + +describe('handlers > slashcommand', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + const mockCommandExecutorOnly: ISlashCommand = { + command: 'executor-only', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: false, + executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise { + return Promise.resolve(); + }, + }; + + const mockCommandExecutorAndPreview: ISlashCommand = { + command: 'executor-and-preview', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise { + return Promise.resolve(); + }, + previewer(_context, _read, _modify, _http, _persis) { + return Promise.resolve({ i18nTitle: 'test', items: [] }); + }, + executePreviewItem(_item, _context, _read, _modify, _http, _persis) { + return Promise.resolve(); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly); + AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview); + }); + + it('correctly handles execution of a slash command', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = mock.method(mockCommandExecutorOnly, 'executor'); + + const mockRequest = createMockRequest({ method: 'slashcommand:executor-only:executor', params: [mockContext] }); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorOnly, 'executor', [ + mockContext, + ]); + + const context = _spy.mock.calls[0].arguments[0]; + + assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(context.getSender(), { __type: 'sender' }); + assert.deepStrictEqual(context.getArguments(), { __type: 'params' }); + assert.deepStrictEqual(context.getThreadId(), 'threadId'); + assert.deepStrictEqual(context.getTriggerId(), 'triggerId'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], mockAppAccessors.getReader()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getModifier()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getHttp()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getPersistence()); + + _spy.mock.restore(); + }); + + it('correctly handles execution of a slash command previewer', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = mock.method(mockCommandExecutorAndPreview, 'previewer'); + + const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:previewer', params: [mockContext] }); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, 'previewer', [ + mockContext, + ]); + + const context = _spy.mock.calls[0].arguments[0]; + + assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(context.getSender(), { __type: 'sender' }); + assert.deepStrictEqual(context.getArguments(), { __type: 'params' }); + assert.deepStrictEqual(context.getThreadId(), 'threadId'); + assert.deepStrictEqual(context.getTriggerId(), 'triggerId'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], mockAppAccessors.getReader()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getModifier()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getHttp()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getPersistence()); + + _spy.mock.restore(); + }); + + it('correctly handles execution of a slash command preview item executor', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const mockPreviewItem = { + id: 'previewItemId', + type: 'image', + value: 'https://example.com/image.png', + }; + + const _spy = mock.method(mockCommandExecutorAndPreview, 'executePreviewItem'); + + const mockRequest = createMockRequest({ + method: 'slashcommand:executor-and-preview:executePreviewItem', + params: [mockPreviewItem, mockContext], + }); + + await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, [ + mockPreviewItem, + mockContext, + ]); + + const context = _spy.mock.calls[0].arguments[1]; + + assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`); + assert.deepStrictEqual(context.getSender(), { __type: 'sender' }); + assert.deepStrictEqual(context.getArguments(), { __type: 'params' }); + assert.deepStrictEqual(context.getThreadId(), 'threadId'); + assert.deepStrictEqual(context.getTriggerId(), 'triggerId'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getReader()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getModifier()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getHttp()); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[5], mockAppAccessors.getPersistence()); + + _spy.mock.restore(); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/uikit-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/uikit-handler.test.ts new file mode 100644 index 0000000000000..500bf957ecf71 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/uikit-handler.test.ts @@ -0,0 +1,105 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it } from 'node:test'; + +import jsonrpc, { type Defined } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { Logger } from '../../lib/logger'; +import type { RequestContext } from '../../lib/requestContext'; +import handleUIKitInteraction, { + UIKitActionButtonInteractionContext, + UIKitBlockInteractionContext, + UIKitLivechatBlockInteractionContext, + UIKitViewCloseInteractionContext, + UIKitViewSubmitInteractionContext, +} from '../uikit/handler'; + +function makeMockRequest(method: string, payload: { [k: string]: Defined }): RequestContext { + return Object.assign(jsonrpc.request(1, method, [payload]), { + context: { + logger: new Logger(method), + }, + }); +} + +describe('handlers > uikit', () => { + const mockApp = { + getID: (): string => 'appId', + executeBlockActionHandler: (context: any): Promise => Promise.resolve(context), + executeViewSubmitHandler: (context: any): Promise => Promise.resolve(context), + executeViewClosedHandler: (context: any): Promise => Promise.resolve(context), + executeActionButtonHandler: (context: any): Promise => Promise.resolve(context), + executeLivechatBlockActionHandler: (context: any): Promise => Promise.resolve(context), + }; + + beforeEach(() => { + AppObjectRegistry.set('app', mockApp); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('successfully handles a call for "executeBlockActionHandler"', async () => { + const request = makeMockRequest('app:executeBlockActionHandler', { + actionId: 'actionId', + blockId: 'blockId', + value: 'value', + viewId: 'viewId', + }); + + const result = await handleUIKitInteraction(request); + assert.ok(result instanceof UIKitBlockInteractionContext, `Expected instance of ${UIKitBlockInteractionContext.name}`); + }); + + it('successfully handles a call for "executeViewSubmitHandler"', async () => { + const request = makeMockRequest('app:executeViewSubmitHandler', { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + values: {}, + }); + + const result = await handleUIKitInteraction(request); + assert.ok(result instanceof UIKitViewSubmitInteractionContext, `Expected instance of ${UIKitViewSubmitInteractionContext.name}`); + }); + + it('successfully handles a call for "executeViewClosedHandler"', async () => { + const request = makeMockRequest('app:executeViewClosedHandler', { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }); + + const result = await handleUIKitInteraction(request); + assert.ok(result instanceof UIKitViewCloseInteractionContext, `Expected instance of ${UIKitViewCloseInteractionContext.name}`); + }); + + it('successfully handles a call for "executeActionButtonHandler"', async () => { + const request = makeMockRequest('app:executeActionButtonHandler', { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }); + + const result = await handleUIKitInteraction(request); + assert.ok(result instanceof UIKitActionButtonInteractionContext, `Expected instance of ${UIKitActionButtonInteractionContext.name}`); + }); + + it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => { + const request = makeMockRequest('app:executeLivechatBlockActionHandler', { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + visitor: {}, + isAppUser: true, + room: {}, + }); + + const result = await handleUIKitInteraction(request); + assert.ok(result instanceof UIKitLivechatBlockInteractionContext, `Expected instance of ${UIKitLivechatBlockInteractionContext.name}`); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/upload-event-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/upload-event-handler.test.ts new file mode 100644 index 0000000000000..5b5948fda8983 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/upload-event-handler.test.ts @@ -0,0 +1,123 @@ +import * as assert from 'node:assert'; +import { Buffer } from 'node:buffer'; +import { randomUUID } from 'node:crypto'; +import { writeFile, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import type { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads/IPreFileUpload'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { createMockRequest } from './helpers/mod'; +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import handleUploadEvents from '../app/handleUploadEvents'; +import { Errors } from '../lib/assertions'; + +describe('handlers > upload', () => { + let app: App & IPreFileUpload; + let tmpFilePath: string; + let file: IUploadDetails; + + beforeEach(async () => { + AppObjectRegistry.clear(); + + tmpFilePath = join(tmpdir(), `test-upload-${randomUUID()}.txt`); + + app = { + extendConfiguration: () => {}, + executePreFileUpload: () => Promise.resolve(), + } as unknown as App & IPreFileUpload; + + AppObjectRegistry.set('app', app); + + const content = 'Temp file for testing'; + + await writeFile(tmpFilePath, content, 'utf-8'); + + file = { + name: 'TempFile.txt', + size: content.length, + type: 'text/plain', + rid: 'RandomRoomId', + userId: 'RandomUserId', + }; + }); + + afterEach(async () => { + await unlink(tmpFilePath).catch((e: any) => e?.code !== 'ENOENT' && console.warn(`Failed to remove temp file at ${tmpFilePath}`, e)); + }); + + it('correctly handles valid parameters', async () => { + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }), + ); + + assert.ok(!(result instanceof JsonRpcError), 'result is JsonRpcError'); + }); + + it('correctly loads the file contents for IPreFileUpload', async () => { + const _spy = mock.method(app as any, 'executePreFileUpload'); + + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }), + ); + + assert.ok(!(result instanceof JsonRpcError), 'result is JsonRpcError'); + assert.strictEqual(_spy.mock.calls.length, 1); + assert.ok((_spy.mock.calls[0].arguments[0] as any)?.content instanceof Buffer, 'Expected content to be a Buffer'); + + _spy.mock.restore(); + }); + + it('fails when app object is not on registry', async () => { + AppObjectRegistry.clear(); + + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }), + ); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.deepStrictEqual((result as JsonRpcError).data.code, Errors.DRT_APP_NOT_AVAILABLE); + }); + + it('fails when the app does not implement the IPreFileUpload event handler', async () => { + delete (app as any).executePreFileUpload; + + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }), + ); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.deepStrictEqual((result as JsonRpcError).data.code, Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING); + }); + + it('fails when "file" is not a proper IUploadDetails object', async () => { + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file: { nope: 'bad' }, path: tmpFilePath }] }), + ); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.ok((result as JsonRpcError).data.err.includes('Expected IUploadDetails')); + }); + + it('fails when "path" is not a proper string', async () => { + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: {} }] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.ok((result as JsonRpcError).data.err.includes('Expected string')); + }); + + it('fails when "path" is not a readable file path', async () => { + await unlink(tmpFilePath); + + const result = await handleUploadEvents( + createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }), + ); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.deepStrictEqual((result as JsonRpcError).data.code, 'ENOENT'); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/tests/videoconference-handler.test.ts b/packages/apps/base-runtime/src/handlers/tests/videoconference-handler.test.ts new file mode 100644 index 0000000000000..325c7e772921e --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/tests/videoconference-handler.test.ts @@ -0,0 +1,112 @@ +import * as assert from 'node:assert'; +import { beforeEach, describe, it, mock } from 'node:test'; + +import { JsonRpcError } from 'jsonrpc-lite'; + +import videoconfHandler from '../videoconference-handler'; +import { createMockRequest } from './helpers/mod'; +import { AppObjectRegistry } from '../../AppObjectRegistry'; + +describe('handlers > videoconference', () => { + const mockMethodWithoutParam = (read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok none'); + const mockMethodWithOneParam = (call: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok one'); + const mockMethodWithTwoParam = (call: any, user: any, read: any, modify: any, http: any, persis: any): Promise => + Promise.resolve('ok two'); + const mockMethodWithThreeParam = (call: any, user: any, options: any, read: any, modify: any, http: any, persis: any): Promise => + Promise.resolve('ok three'); + const mockProvider = { + empty: mockMethodWithoutParam, + one: mockMethodWithOneParam, + two: mockMethodWithTwoParam, + three: mockMethodWithThreeParam, + notAFunction: true, + error: () => { + throw new Error('Method execution error example'); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('videoConfProvider:test-provider', mockProvider); + }); + + it('correctly handles execution of a videoconf method without additional params', async () => { + const _spy = mock.method(mockProvider, 'empty'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:empty', params: [] })); + + assert.deepStrictEqual(result, 'ok none'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 4); + + _spy.mock.restore(); + }); + + it('correctly handles execution of a videoconf method with one param', async () => { + const _spy = mock.method(mockProvider, 'one'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:one', params: ['call'] })); + + assert.deepStrictEqual(result, 'ok one'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 5); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call'); + + _spy.mock.restore(); + }); + + it('correctly handles execution of a videoconf method with two params', async () => { + const _spy = mock.method(mockProvider, 'two'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:two', params: ['call', 'user'] })); + + assert.deepStrictEqual(result, 'ok two'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'user'); + + _spy.mock.restore(); + }); + + it('correctly handles execution of a videoconf method with three params', async () => { + const _spy = mock.method(mockProvider, 'three'); + + const result = await videoconfHandler( + createMockRequest({ method: 'videoconference:test-provider:three', params: ['call', 'user', 'options'] }), + ); + + assert.deepStrictEqual(result, 'ok three'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 7); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'user'); + assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], 'options'); + + _spy.mock.restore(); + }); + + it('correctly handles an error on execution of a videoconf method', async () => { + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:error', params: [] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual((result as any).message, 'Method execution error example'); + assert.strictEqual((result as any).code, -32000); + }); + + it('correctly handles an error when provider is not found', async () => { + const providerName = 'error-provider'; + const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:method`, params: [] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual((result as any).message, `Provider ${providerName} not found`); + assert.strictEqual((result as any).code, -32000); + }); + + it('correctly handles an error if method is not a function of provider', async () => { + const methodName = 'notAFunction'; + const providerName = 'test-provider'; + const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:${methodName}`, params: [] })); + + assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`); + assert.strictEqual((result as any).message, 'Method not found'); + assert.strictEqual((result as any).code, -32601); + assert.strictEqual((result as any).data.message, `Method ${methodName} not found on provider ${providerName}`); + }); +}); diff --git a/packages/apps/base-runtime/src/handlers/uikit/handler.ts b/packages/apps/base-runtime/src/handlers/uikit/handler.ts new file mode 100644 index 0000000000000..9e69692c99a41 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/uikit/handler.ts @@ -0,0 +1,101 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import type { + IUIKitBlockIncomingInteraction, + IUIKitViewSubmitIncomingInteraction, + IUIKitViewCloseIncomingInteraction, + IUIKitActionButtonIncomingInteraction, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionTypes'; +import { + UIKitBlockInteractionContext, + UIKitViewSubmitInteractionContext, + UIKitViewCloseInteractionContext, + UIKitActionButtonInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionContext'; +import type { IUIKitLivechatBlockIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatIncomingInteractionType'; +import { UIKitLivechatBlockInteractionContext } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatInteractionContext'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { AppAccessorsInstance } from '../../lib/accessors/mod'; +import type { RequestContext } from '../../lib/requestContext'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; +import { isOneOf } from '../lib/assertions'; + +export const uikitInteractions = [ + 'executeBlockActionHandler', + 'executeViewSubmitHandler', + 'executeViewClosedHandler', + 'executeActionButtonHandler', + 'executeLivechatBlockActionHandler', +] as const; + +export { + UIKitBlockInteractionContext, + UIKitViewSubmitInteractionContext, + UIKitViewCloseInteractionContext, + UIKitActionButtonInteractionContext, + UIKitLivechatBlockInteractionContext, +}; + +export default async function handleUIKitInteraction(request: RequestContext): Promise { + const { method: reqMethod, params } = request; + const [, method] = reqMethod.split(':'); + + if (!isOneOf(method, uikitInteractions)) { + return JsonRpcError.methodNotFound(null); + } + + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams(null); + } + + const app = AppObjectRegistry.get('app'); + + const interactionHandler = app?.[method as keyof App] as unknown; + + if (!app || typeof interactionHandler !== 'function') { + return JsonRpcError.methodNotFound({ + message: `App does not implement method "${method}"`, + }); + } + + const [payload] = params as [Record]; + + if (!payload) { + return JsonRpcError.invalidParams(null); + } + + let context; + + switch (method) { + case 'executeBlockActionHandler': + context = new UIKitBlockInteractionContext(payload as unknown as IUIKitBlockIncomingInteraction); + break; + case 'executeViewSubmitHandler': + context = new UIKitViewSubmitInteractionContext(payload as unknown as IUIKitViewSubmitIncomingInteraction); + break; + case 'executeViewClosedHandler': + context = new UIKitViewCloseInteractionContext(payload as unknown as IUIKitViewCloseIncomingInteraction); + break; + case 'executeActionButtonHandler': + context = new UIKitActionButtonInteractionContext(payload as unknown as IUIKitActionButtonIncomingInteraction); + break; + case 'executeLivechatBlockActionHandler': + context = new UIKitLivechatBlockInteractionContext(payload as unknown as IUIKitLivechatBlockIncomingInteraction); + break; + } + + try { + return await interactionHandler.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch (e) { + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps/base-runtime/src/handlers/videoconference-handler.ts b/packages/apps/base-runtime/src/handlers/videoconference-handler.ts new file mode 100644 index 0000000000000..59a3e33409753 --- /dev/null +++ b/packages/apps/base-runtime/src/handlers/videoconference-handler.ts @@ -0,0 +1,53 @@ +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider'; +import type { Defined } from 'jsonrpc-lite'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; +import { AppAccessorsInstance } from '../lib/accessors/mod'; +import type { RequestContext } from '../lib/requestContext'; +import { wrapComposedApp } from '../lib/wrapAppForRequest'; + +export default async function videoConferenceHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const { logger } = request.context; + + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`videoConfProvider:${providerName}`); + + if (!provider) { + return new JsonRpcError(`Provider ${providerName} not found`, -32000); + } + + const method = provider[methodName as keyof IVideoConfProvider]; + + if (typeof method !== 'function') { + return JsonRpcError.methodNotFound({ + message: `Method ${methodName} not found on provider ${providerName}`, + }); + } + + const [videoconf, user, options] = params as Array; + + logger.debug(`Executing ${methodName} on video conference provider...`); + + const args = [...(videoconf ? [videoconf] : []), ...(user ? [user] : []), ...(options ? [options] : [])]; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const result = await (method as Function).apply(wrapComposedApp(provider, request), [ + ...args, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger.debug(`Video Conference Provider's ${methodName} was successfully executed.`); + + return result; + } catch (e) { + logger.debug(`Video Conference Provider's ${methodName} was unsuccessful.`); + return new JsonRpcError(e.message, -32000); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/BlockBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/BlockBuilder.ts new file mode 100644 index 0000000000000..69798dd311d7c --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/BlockBuilder.ts @@ -0,0 +1,16 @@ +import { BlockBuilder as AppsEngineBlockBuilder } from '@rocket.chat/apps-engine/definition/uikit/blocks/BlockBuilder'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; + +/** + * Local BlockBuilder that extends the apps-engine BlockBuilder. + * It overrides the constructor to source the appId from the registry + * instead of requiring it as a constructor argument. + * + * @deprecated please prefer the rocket.chat/ui-kit components + */ +export class BlockBuilder extends AppsEngineBlockBuilder { + constructor() { + super(String(AppObjectRegistry.get('id') ?? '')); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/DiscussionBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/DiscussionBuilder.ts new file mode 100644 index 0000000000000..649ddce2a6e63 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/DiscussionBuilder.ts @@ -0,0 +1,48 @@ +import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; + +import { RoomBuilder } from './RoomBuilder'; + +export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { + public declare kind: RocketChatAssociationModel.DISCUSSION; + + private reply?: string; + + private parentMessage?: IMessage; + + constructor(data?: Partial) { + super(data); + this.kind = RocketChatAssociationModel.DISCUSSION; + this.room.type = RoomType.PRIVATE_GROUP; + } + + public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { + this.room.parentRoom = parentRoom; + return this; + } + + public getParentRoom(): IRoom { + return this.room.parentRoom!; + } + + public setReply(reply: string): IDiscussionBuilder { + this.reply = reply; + return this; + } + + public getReply(): string { + return this.reply!; + } + + public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { + this.parentMessage = parentMessage; + return this; + } + + public getParentMessage(): IMessage { + return this.parentMessage!; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/LivechatMessageBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/LivechatMessageBuilder.ts new file mode 100644 index 0000000000000..3b322cb4401ed --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/LivechatMessageBuilder.ts @@ -0,0 +1,196 @@ +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; +import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +import { MessageBuilder } from './MessageBuilder'; + +export interface ILivechatMessage extends EngineLivechatMessage, IMessage {} + +export class LivechatMessageBuilder implements ILivechatMessageBuilder { + public kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; + + private msg: ILivechatMessage; + + constructor(message?: ILivechatMessage) { + this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; + this.msg = message || ({} as ILivechatMessage); + } + + public setData(data: ILivechatMessage): ILivechatMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setRoom(room: IRoom): ILivechatMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): ILivechatMessageBuilder { + this.msg.sender = sender; + delete this.msg.visitor; + + return this; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): ILivechatMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): ILivechatMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): ILivechatMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): ILivechatMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): ILivechatMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public setToken(token: string): ILivechatMessageBuilder { + this.msg.token = token; + return this; + } + + public getToken(): string { + return this.msg.token!; + } + + public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { + this.msg.visitor = visitor; + delete this.msg.sender; + + return this; + } + + public getVisitor(): IVisitor { + return this.msg.visitor; + } + + public getMessage(): ILivechatMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + if (this.msg.room.type !== RoomType.LIVE_CHAT) { + throw new Error('The room is not a Livechat room'); + } + + return this.msg; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(this.msg as IMessage); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/MessageBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/MessageBuilder.ts new file mode 100644 index 0000000000000..667de3d1d2032 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/MessageBuilder.ts @@ -0,0 +1,267 @@ +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import { BlockBuilder } from './BlockBuilder'; + +export class MessageBuilder implements IMessageBuilder { + public kind: RocketChatAssociationModel.MESSAGE; + + private msg: IMessage; + + private changes: Partial = {}; + + private attachmentsChanged = false; + + private customFieldsChanged = false; + + constructor(message?: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + this.msg = message || ({} as IMessage); + } + + public setData(data: IMessage): IMessageBuilder { + delete data.id; + this.msg = data; + + return this as IMessageBuilder; + } + + public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { + this.msg = data; + this.msg.editor = editor; + this.msg.editedAt = new Date(); + + this.changes = structuredClone(this.msg); + + return this as IMessageBuilder; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + this.changes.threadId = threadId; + + return this as IMessageBuilder; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + this.changes.room = room; + + return this as IMessageBuilder; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + this.changes.sender = sender; + + return this as IMessageBuilder; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + this.changes.text = text; + + return this as IMessageBuilder; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + this.changes.emoji = emoji; + + return this as IMessageBuilder; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + this.changes.avatarUrl = avatarUrl; + + return this as IMessageBuilder; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.alias = alias; + this.changes.alias = alias; + + return this as IMessageBuilder; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments?.[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public removeAttachment(position: number): IMessageBuilder { + if (!this.msg.attachments?.[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + this.changes.editor = user; + + return this as IMessageBuilder; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + this.changes.groupable = groupable; + + return this as IMessageBuilder; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.parseUrls = parseUrls; + this.changes.parseUrls = parseUrls; + + return this as IMessageBuilder; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public getMessage(): IMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + return this.msg; + } + + public addBlocks(blocks: BlockBuilder | Array) { + if (!Array.isArray(this.msg.blocks)) { + this.msg.blocks = []; + } + + if (blocks instanceof BlockBuilder) { + this.msg.blocks.push(...blocks.getBlocks()); + } else { + this.msg.blocks.push(...blocks); + } + + return this as IMessageBuilder; + } + + public setBlocks(blocks: BlockBuilder | Array) { + const blockArray: Array = blocks instanceof BlockBuilder ? blocks.getBlocks() : blocks; + + this.msg.blocks = blockArray; + this.changes.blocks = blockArray; + + return this as IMessageBuilder; + } + + public getBlocks() { + return this.msg.blocks!; + } + + public addCustomField(key: string, value: unknown): IMessageBuilder { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + this.customFieldsChanged = true; + + return this as IMessageBuilder; + } + + public getChanges(): Partial { + const changes: typeof this.changes = structuredClone(this.changes); + + if (this.attachmentsChanged) { + changes.attachments = structuredClone(this.msg.attachments); + } + + if (this.customFieldsChanged) { + changes.customFields = structuredClone(this.msg.customFields); + } + + return changes; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/RoomBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/RoomBuilder.ts new file mode 100644 index 0000000000000..748cedc15a710 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/RoomBuilder.ts @@ -0,0 +1,191 @@ +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +export class RoomBuilder implements IRoomBuilder { + public kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; + + protected room: IRoom; + + private members: Array; + + private changes: Partial = {}; + + private customFieldsChanged = false; + + constructor(data?: Partial) { + this.kind = RocketChatAssociationModel.ROOM; + this.room = (data || { customFields: {} }) as IRoom; + this.members = []; + } + + public setData(data: Partial): IRoomBuilder { + delete data.id; + this.room = data as IRoom; + + this.changes = structuredClone(this.room); + + return this; + } + + public setDisplayName(name: string): IRoomBuilder { + this.room.displayName = name; + this.changes.displayName = name; + + return this; + } + + public getDisplayName(): string { + return this.room.displayName!; + } + + public setSlugifiedName(name: string): IRoomBuilder { + this.room.slugifiedName = name; + this.changes.slugifiedName = name; + + return this; + } + + public getSlugifiedName(): string { + return this.room.slugifiedName; + } + + public setType(type: RoomType): IRoomBuilder { + this.room.type = type; + this.changes.type = type; + + return this; + } + + public getType(): RoomType { + return this.room.type; + } + + public setCreator(creator: IUser): IRoomBuilder { + this.room.creator = creator; + this.changes.creator = creator; + + return this; + } + + public getCreator(): IUser { + return this.room.creator; + } + + /** + * @deprecated + */ + public addUsername(username: string): IRoomBuilder { + this.addMemberToBeAddedByUsername(username); + return this; + } + + /** + * @deprecated + */ + public setUsernames(usernames: Array): IRoomBuilder { + this.setMembersToBeAddedByUsernames(usernames); + return this; + } + + /** + * @deprecated + */ + public getUsernames(): Array { + const usernames = this.getMembersToBeAddedUsernames(); + if (usernames && usernames.length > 0) { + return usernames; + } + return this.room.usernames || []; + } + + public addMemberToBeAddedByUsername(username: string): IRoomBuilder { + this.members.push(username); + return this; + } + + public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { + this.members = usernames; + return this; + } + + public getMembersToBeAddedUsernames(): Array { + return this.members; + } + + public setDefault(isDefault: boolean): IRoomBuilder { + this.room.isDefault = isDefault; + this.changes.isDefault = isDefault; + + return this; + } + + public getIsDefault(): boolean { + return this.room.isDefault!; + } + + public setReadOnly(isReadOnly: boolean): IRoomBuilder { + this.room.isReadOnly = isReadOnly; + this.changes.isReadOnly = isReadOnly; + + return this; + } + + public getIsReadOnly(): boolean { + return this.room.isReadOnly!; + } + + public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { + this.room.displaySystemMessages = displaySystemMessages; + this.changes.displaySystemMessages = displaySystemMessages; + + return this; + } + + public getDisplayingOfSystemMessages(): boolean { + return this.room.displaySystemMessages!; + } + + public addCustomField(key: string, value: object): IRoomBuilder { + if (typeof this.room.customFields !== 'object') { + this.room.customFields = {}; + } + + this.room.customFields[key] = value; + + this.customFieldsChanged = true; + + return this; + } + + public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { + this.room.customFields = fields; + this.customFieldsChanged = true; + + return this; + } + + public getCustomFields(): { [key: string]: object } { + return this.room.customFields!; + } + + public getUserIds(): Array { + return this.room.userIds!; + } + + public getRoom(): IRoom { + return this.room; + } + + public getChanges() { + const changes: Partial = structuredClone(this.changes); + + if (this.customFieldsChanged) { + changes.customFields = structuredClone(this.room.customFields); + } + + return changes; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/UserBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/UserBuilder.ts new file mode 100644 index 0000000000000..35a4cf1411837 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/UserBuilder.ts @@ -0,0 +1,75 @@ +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; +import type { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail'; +import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings'; + +export class UserBuilder implements IUserBuilder { + public kind: RocketChatAssociationModel.USER; + + private user: Partial; + + constructor(user?: Partial) { + this.kind = RocketChatAssociationModel.USER; + this.user = user || ({} as Partial); + } + + public setData(data: Partial): IUserBuilder { + delete data.id; + this.user = data; + + return this; + } + + public setEmails(emails: Array): IUserBuilder { + this.user.emails = emails; + return this; + } + + public getEmails(): Array { + return this.user.emails!; + } + + public setDisplayName(name: string): IUserBuilder { + this.user.name = name; + return this; + } + + public getDisplayName(): string { + return this.user.name!; + } + + public setUsername(username: string): IUserBuilder { + this.user.username = username; + return this; + } + + public getUsername(): string { + return this.user.username!; + } + + public setRoles(roles: Array): IUserBuilder { + this.user.roles = roles; + return this; + } + + public getRoles(): Array { + return this.user.roles!; + } + + public getSettings(): Partial { + return this.user.settings; + } + + public getUser(): Partial { + if (!this.user.username) { + throw new Error('The "username" property is required.'); + } + + if (!this.user.name) { + throw new Error('The "name" property is required.'); + } + + return this.user; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/builders/VideoConferenceBuilder.ts b/packages/apps/base-runtime/src/lib/accessors/builders/VideoConferenceBuilder.ts new file mode 100644 index 0000000000000..27e607fbc5f86 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/builders/VideoConferenceBuilder.ts @@ -0,0 +1,87 @@ +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IGroupVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; + +export type AppVideoConference = Pick & { + createdBy: IGroupVideoConference['createdBy']['_id']; +}; + +export class VideoConferenceBuilder implements IVideoConferenceBuilder { + public kind: RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; + + protected call: AppVideoConference; + + constructor(data?: Partial) { + this.call = (data || {}) as AppVideoConference; + } + + public setData(data: Partial): IVideoConferenceBuilder { + this.call = { + rid: data.rid!, + createdBy: data.createdBy, + providerName: data.providerName!, + title: data.title!, + discussionRid: data.discussionRid, + }; + + return this; + } + + public setRoomId(rid: string): IVideoConferenceBuilder { + this.call.rid = rid; + return this; + } + + public getRoomId(): string { + return this.call.rid; + } + + public setCreatedBy(userId: string): IVideoConferenceBuilder { + this.call.createdBy = userId; + return this; + } + + public getCreatedBy(): string { + return this.call.createdBy; + } + + public setProviderName(userId: string): IVideoConferenceBuilder { + this.call.providerName = userId; + return this; + } + + public getProviderName(): string { + return this.call.providerName; + } + + public setProviderData(data: Record | undefined): IVideoConferenceBuilder { + this.call.providerData = data; + return this; + } + + public getProviderData(): Record { + return this.call.providerData!; + } + + public setTitle(userId: string): IVideoConferenceBuilder { + this.call.title = userId; + return this; + } + + public getTitle(): string { + return this.call.title; + } + + public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder { + this.call.discussionRid = rid; + return this; + } + + public getDiscussionRid(): AppVideoConference['discussionRid'] { + return this.call.discussionRid; + } + + public getVideoConference(): AppVideoConference { + return this.call; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/extenders/HttpExtender.ts b/packages/apps/base-runtime/src/lib/accessors/extenders/HttpExtender.ts new file mode 100644 index 0000000000000..03a39ff1fedc2 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/extenders/HttpExtender.ts @@ -0,0 +1,58 @@ +import type { IHttpExtend, IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; + +export class HttpExtend implements IHttpExtend { + private headers: Map; + + private params: Map; + + private requests: Array; + + private responses: Array; + + constructor() { + this.headers = new Map(); + this.params = new Map(); + this.requests = []; + this.responses = []; + } + + public provideDefaultHeader(key: string, value: string): void { + this.headers.set(key, value); + } + + public provideDefaultHeaders(headers: { [key: string]: string }): void { + Object.keys(headers).forEach((key) => this.headers.set(key, headers[key])); + } + + public provideDefaultParam(key: string, value: string): void { + this.params.set(key, value); + } + + public provideDefaultParams(params: { [key: string]: string }): void { + Object.keys(params).forEach((key) => this.params.set(key, params[key])); + } + + public providePreRequestHandler(handler: IHttpPreRequestHandler): void { + this.requests.push(handler); + } + + public providePreResponseHandler(handler: IHttpPreResponseHandler): void { + this.responses.push(handler); + } + + public getDefaultHeaders(): Map { + return new Map(this.headers); + } + + public getDefaultParams(): Map { + return new Map(this.params); + } + + public getPreRequestHandlers(): Array { + return Array.from(this.requests); + } + + public getPreResponseHandlers(): Array { + return Array.from(this.responses); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/extenders/MessageExtender.ts b/packages/apps/base-runtime/src/lib/accessors/extenders/MessageExtender.ts new file mode 100644 index 0000000000000..2ba76631bb0ec --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/extenders/MessageExtender.ts @@ -0,0 +1,60 @@ +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; + +export class MessageExtender implements IMessageExtender { + public readonly kind: RocketChatAssociationModel.MESSAGE; + + constructor(private msg: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + + if (!Array.isArray(msg.attachments)) { + this.msg.attachments = []; + } + } + + public addCustomField(key: string, value: unknown): IMessageExtender { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this; + } + + public addAttachment(attachment: IMessageAttachment): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments!.push(attachment); + + return this; + } + + public addAttachments(attachments: Array): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments = this.msg.attachments!.concat(attachments); + + return this; + } + + public getMessage(): IMessage { + return structuredClone(this.msg); + } + + private ensureAttachment(): void { + if (!Array.isArray(this.msg.attachments)) { + this.msg.attachments = []; + } + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/extenders/RoomExtender.ts b/packages/apps/base-runtime/src/lib/accessors/extenders/RoomExtender.ts new file mode 100644 index 0000000000000..3a22c9277c626 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/extenders/RoomExtender.ts @@ -0,0 +1,55 @@ +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +export class RoomExtender implements IRoomExtender { + public kind: RocketChatAssociationModel.ROOM; + + private members: Array; + + constructor(private room: IRoom) { + this.kind = RocketChatAssociationModel.ROOM; + this.members = []; + } + + public addCustomField(key: string, value: unknown): IRoomExtender { + if (!this.room.customFields) { + this.room.customFields = {}; + } + + if (this.room.customFields[key]) { + throw new Error(`The room already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.room.customFields[key] = value; + + return this; + } + + public addMember(user: IUser): IRoomExtender { + if (this.members.find((u) => u.username === user.username)) { + throw new Error('The user is already in the room.'); + } + + this.members.push(user); + + return this; + } + + public getMembersBeingAdded(): Array { + return this.members; + } + + public getUsernamesOfMembersBeingAdded(): Array { + return this.members.map((u) => u.username); + } + + public getRoom(): IRoom { + return structuredClone(this.room); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/extenders/VideoConferenceExtend.ts b/packages/apps/base-runtime/src/lib/accessors/extenders/VideoConferenceExtend.ts new file mode 100644 index 0000000000000..f6ab449df1055 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/extenders/VideoConferenceExtend.ts @@ -0,0 +1,63 @@ +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { VideoConference, VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser'; + +export class VideoConferenceExtender implements IVideoConferenceExtender { + public kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + constructor(private videoConference: VideoConference) { + this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE; + } + + public setProviderData(value: Record): IVideoConferenceExtender { + this.videoConference.providerData = value; + + return this; + } + + public setStatus(value: VideoConference['status']): IVideoConferenceExtender { + this.videoConference.status = value; + + return this; + } + + public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender { + this.videoConference.endedBy = { + _id: value, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }; + + return this; + } + + public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender { + this.videoConference.endedAt = value; + + return this; + } + + public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender { + this.videoConference.users.push({ + _id: userId, + ts, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }); + + return this; + } + + public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender { + this.videoConference.discussionRid = rid; + + return this; + } + + public getVideoConference(): VideoConference { + return structuredClone(this.videoConference); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/formatResponseErrorHandler.ts b/packages/apps/base-runtime/src/lib/accessors/formatResponseErrorHandler.ts new file mode 100644 index 0000000000000..6840c3ab5baa3 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/formatResponseErrorHandler.ts @@ -0,0 +1,14 @@ +import { ErrorObject } from 'jsonrpc-lite'; + +// deno-lint-ignore no-explicit-any -- that is the type we get from `catch` +export const formatErrorResponse = (error: any): Error => { + if (error instanceof ErrorObject || typeof error?.error?.message === 'string') { + return new Error(error.error.message); + } + + if (error instanceof Error) { + return error; + } + + return new Error('An unknown error occurred', { cause: error }); +}; diff --git a/packages/apps/base-runtime/src/lib/accessors/http.ts b/packages/apps/base-runtime/src/lib/accessors/http.ts new file mode 100644 index 0000000000000..70b16e5250720 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/http.ts @@ -0,0 +1,95 @@ +import type { IHttp, IHttpExtend, IHttpRequest, IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence'; +import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import type * as Messenger from '../messenger'; +import { formatErrorResponse } from './formatResponseErrorHandler'; + +type RequestMethod = 'get' | 'post' | 'put' | 'head' | 'delete' | 'patch'; + +export class Http implements IHttp { + private httpExtender: IHttpExtend; + + private read: IRead; + + private persistence: IPersistence; + + private senderFn: typeof Messenger.sendRequest; + + constructor(read: IRead, persistence: IPersistence, httpExtender: IHttpExtend, senderFn: typeof Messenger.sendRequest) { + this.read = read; + this.persistence = persistence; + this.httpExtender = httpExtender; + this.senderFn = senderFn; + // this.httpExtender = new HttpExtend(); + } + + public get(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'get', options); + } + + public put(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'put', options); + } + + public post(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'post', options); + } + + public del(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'delete', options); + } + + public patch(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'patch', options); + } + + private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise { + let request = options || {}; + + if (typeof request.headers === 'undefined') { + request.headers = {}; + } + + this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => { + if (typeof request.headers?.[key] !== 'string') { + request.headers![key] = value; + } + }); + + if (typeof request.params === 'undefined') { + request.params = {}; + } + + this.httpExtender.getDefaultParams().forEach((value: string, key: string) => { + if (typeof request.params?.[key] !== 'string') { + request.params![key] = value; + } + }); + + for (const handler of this.httpExtender.getPreRequestHandlers()) { + request = await handler.executePreHttpRequest(url, request, this.read, this.persistence); + } + + let { result: response } = await this.senderFn({ + method: `bridges:getHttpBridge:doCall`, + params: [ + { + appId: AppObjectRegistry.get('id'), + method, + url, + request, + }, + ], + }).catch((error) => { + throw formatErrorResponse(error); + }); + + for (const handler of this.httpExtender.getPreResponseHandlers()) { + response = await handler.executePreHttpResponse(response as IHttpResponse, this.read, this.persistence); + } + + return response as IHttpResponse; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/mod.ts b/packages/apps/base-runtime/src/lib/accessors/mod.ts new file mode 100644 index 0000000000000..acd1df5a2a489 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/mod.ts @@ -0,0 +1,356 @@ +import type { IApiExtend } from '@rocket.chat/apps-engine/definition/accessors/IApiExtend'; +import type { IAppAccessors } from '@rocket.chat/apps-engine/definition/accessors/IAppAccessors'; +import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationExtend'; +import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify'; +import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead'; +import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite'; +import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; +import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify'; +import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import type { IOutboundCommunicationProviderExtend } from '@rocket.chat/apps-engine/definition/accessors/IOutboundCommunicationProviderExtend'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence'; +import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead'; +import type { ISchedulerExtend } from '@rocket.chat/apps-engine/definition/accessors/ISchedulerExtend'; +import type { ISlashCommandsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsExtend'; +import type { ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsModify'; +import type { IVideoConfProvidersExtend } from '@rocket.chat/apps-engine/definition/accessors/IVideoConfProvidersExtend'; +import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata'; +import type { + IOutboundPhoneMessageProvider, + IOutboundEmailMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider'; + +import { HttpExtend } from './extenders/HttpExtender'; +import { formatErrorResponse } from './formatResponseErrorHandler'; +import { Http } from './http'; +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import * as Messenger from '../messenger'; +import { ModifyCreator } from './modify/ModifyCreator'; +import { ModifyExtender } from './modify/ModifyExtender'; +import { ModifyUpdater } from './modify/ModifyUpdater'; +import { Notifier } from './notifier'; + +/** Helper: extends T with an internal _proxy property used for delegation. */ +type WithProxy = T & { _proxy: T }; + +const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; + +// We need to create this object first thing, as we'll handle references to it later on +if (!AppObjectRegistry.has('apiEndpoints')) { + AppObjectRegistry.set('apiEndpoints', []); +} + +export class AppAccessors { + private defaultAppAccessors?: IAppAccessors; + + private environmentRead?: IEnvironmentRead; + + private environmentWriter?: IEnvironmentWrite; + + private configModifier?: IConfigurationModify; + + private configExtender?: IConfigurationExtend; + + private reader?: IRead; + + private modifier?: IModify; + + private persistence?: IPersistence; + + private creator?: ModifyCreator; + + private updater?: ModifyUpdater; + + private extender?: ModifyExtender; + + private httpExtend: IHttpExtend = new HttpExtend(); + + private http?: IHttp; + + private notifier?: INotifier; + + private proxify: (namespace: string, overrides?: Record unknown>) => T; + + constructor(private readonly senderFn: typeof Messenger.sendRequest) { + this.proxify = (namespace: string, overrides: Record unknown> = {}): T => + new Proxy( + { __kind: `accessor:${namespace}` }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => { + // We don't want to send a request for this prop + if (prop === 'toJSON') { + return {}; + } + + // If the prop is inteded to be overriden by the caller + if (prop in overrides) { + return overrides[prop].apply(undefined, params); + } + + return senderFn({ + method: `accessor:${namespace}:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }, + ) as T; + + this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn()); + this.notifier = new Notifier(this.getSenderFn()); + } + + public getSenderFn() { + return this.senderFn; + } + + public getEnvironmentRead(): IEnvironmentRead { + if (!this.environmentRead) { + this.environmentRead = { + getSettings: () => this.proxify('getEnvironmentRead:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentRead:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getEnvironmentRead:getEnvironmentVariables'), + }; + } + + return this.environmentRead; + } + + public getEnvironmentWrite() { + if (!this.environmentWriter) { + this.environmentWriter = { + getSettings: () => this.proxify('getEnvironmentWrite:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentWrite:getServerSettings'), + }; + } + + return this.environmentWriter; + } + + public getConfigurationModify() { + if (!this.configModifier) { + const slashCommandsModify: WithProxy = { + _proxy: this.proxify('getConfigurationModify:slashCommands'), + modifySlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.modifySlashCommand(slashcommand); + }, + disableSlashCommand(command: string) { + return this._proxy.disableSlashCommand(command); + }, + enableSlashCommand(command: string) { + return this._proxy.enableSlashCommand(command); + }, + }; + + this.configModifier = { + scheduler: this.proxify('getConfigurationModify:scheduler'), + slashCommands: slashCommandsModify, + serverSettings: this.proxify('getConfigurationModify:serverSettings'), + }; + } + + return this.configModifier; + } + + public getConfigurationExtend() { + if (!this.configExtender) { + const { senderFn } = this; + + const apiExtend: WithProxy = { + _proxy: this.proxify('getConfigurationExtend:api'), + async provideApi(api: IApi) { + const apiEndpoints = AppObjectRegistry.get('apiEndpoints')!; + + api.endpoints.forEach((endpoint) => { + endpoint._availableMethods = httpMethods.filter((method) => typeof endpoint[method] === 'function'); + + // We need to keep a reference to the endpoint around for us to call the executor later + AppObjectRegistry.set(`api:${endpoint.path}`, endpoint); + }); + + const result = await this._proxy.provideApi(api); + + // Let's call the listApis method to cache the info from the endpoints + // Also, since this is a side-effect, we do it async so we can return to the caller + senderFn({ method: 'accessor:api:listApis' }) + .then((response) => apiEndpoints.push(...(response.result as IApiEndpointMetadata[]))) + .catch((err) => err.error); + + return result; + }, + }; + + const schedulerExtend: WithProxy = { + _proxy: this.proxify('getConfigurationExtend:scheduler'), + registerProcessors(processors: IProcessor[]) { + // Store the processor instance to use when the Apps-Engine calls the processor + processors.forEach((processor) => { + AppObjectRegistry.set(`scheduler:${processor.id}`, processor); + }); + + return this._proxy.registerProcessors(processors); + }, + }; + + const videoConfProviders: WithProxy = { + _proxy: this.proxify('getConfigurationExtend:videoConfProviders'), + provideVideoConfProvider(provider: IVideoConfProvider) { + // Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider + AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider); + + return this._proxy.provideVideoConfProvider(provider); + }, + }; + + const outboundCommunication: WithProxy = { + _proxy: this.proxify('getConfigurationExtend:outboundCommunication'), + registerEmailProvider(provider: IOutboundEmailMessageProvider) { + AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); + return this._proxy.registerEmailProvider(provider); + }, + registerPhoneProvider(provider: IOutboundPhoneMessageProvider) { + AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); + return this._proxy.registerPhoneProvider(provider); + }, + }; + + const slashCommandsExtend: WithProxy = { + _proxy: this.proxify('getConfigurationExtend:slashCommands'), + provideSlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.provideSlashCommand(slashcommand); + }, + }; + + this.configExtender = { + ui: this.proxify('getConfigurationExtend:ui'), + http: this.httpExtend, + settings: this.proxify('getConfigurationExtend:settings'), + externalComponents: this.proxify('getConfigurationExtend:externalComponents'), + api: apiExtend, + scheduler: schedulerExtend, + videoConfProviders, + outboundCommunication, + slashCommands: slashCommandsExtend, + }; + } + + return this.configExtender; + } + + public getDefaultAppAccessors() { + if (!this.defaultAppAccessors) { + this.defaultAppAccessors = { + environmentReader: this.getEnvironmentRead(), + environmentWriter: this.getEnvironmentWrite(), + reader: this.getReader(), + http: this.getHttp(), + providedApiEndpoints: AppObjectRegistry.get('apiEndpoints') as IApiEndpointMetadata[], + }; + } + + return this.defaultAppAccessors; + } + + public getReader() { + if (!this.reader) { + this.reader = { + getEnvironmentReader: () => ({ + getSettings: () => this.proxify('getReader:getEnvironmentReader:getSettings'), + getServerSettings: () => this.proxify('getReader:getEnvironmentReader:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getReader:getEnvironmentReader:getEnvironmentVariables'), + }), + getMessageReader: () => this.proxify('getReader:getMessageReader'), + getPersistenceReader: () => this.proxify('getReader:getPersistenceReader'), + getRoomReader: () => this.proxify('getReader:getRoomReader'), + getUserReader: () => this.proxify('getReader:getUserReader'), + getNotifier: () => this.getNotifier(), + getLivechatReader: () => this.proxify('getReader:getLivechatReader'), + getUploadReader: () => this.proxify('getReader:getUploadReader'), + getCloudWorkspaceReader: () => this.proxify('getReader:getCloudWorkspaceReader'), + getVideoConferenceReader: () => this.proxify('getReader:getVideoConferenceReader'), + getOAuthAppsReader: () => this.proxify('getReader:getOAuthAppsReader'), + getThreadReader: () => this.proxify('getReader:getThreadReader'), + getRoleReader: () => this.proxify('getReader:getRoleReader'), + getContactReader: () => this.proxify('getReader:getContactReader'), + getExperimentalReader: () => this.proxify('getReader:getExperimentalReader'), + }; + } + + return this.reader; + } + + public getModifier() { + if (!this.modifier) { + this.modifier = { + getCreator: this.getCreator.bind(this), + getUpdater: this.getUpdater.bind(this), + getExtender: this.getExtender.bind(this), + getDeleter: () => this.proxify('getModifier:getDeleter'), + getNotifier: () => this.getNotifier(), + getUiController: () => this.proxify('getModifier:getUiController'), + getScheduler: () => this.proxify('getModifier:getScheduler'), + getOAuthAppsModifier: () => this.proxify('getModifier:getOAuthAppsModifier'), + getModerationModifier: () => this.proxify('getModifier:getModerationModifier'), + }; + } + + return this.modifier; + } + + public getPersistence() { + if (!this.persistence) { + this.persistence = this.proxify('getPersistence'); + } + + return this.persistence; + } + + public getHttp() { + return this.http; + } + + private getCreator() { + if (!this.creator) { + this.creator = new ModifyCreator(this.senderFn); + } + + return this.creator; + } + + private getUpdater() { + if (!this.updater) { + this.updater = new ModifyUpdater(this.senderFn); + } + + return this.updater; + } + + private getExtender() { + if (!this.extender) { + this.extender = new ModifyExtender(this.senderFn); + } + + return this.extender; + } + + private getNotifier() { + return this.notifier; + } +} + +export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest); diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts new file mode 100644 index 0000000000000..55b5d53e5c7da --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyCreator.ts @@ -0,0 +1,380 @@ +import { randomBytes } from 'node:crypto'; + +import { UIHelper } from '@rocket.chat/apps/dist/server/misc/UIHelper'; +import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; +import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder'; +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; +import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator'; +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder'; +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; +import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; +import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import type * as Messenger from '../../messenger'; +import { BlockBuilder } from '../builders/BlockBuilder'; +import { DiscussionBuilder } from '../builders/DiscussionBuilder'; +import type { ILivechatMessage } from '../builders/LivechatMessageBuilder'; +import { LivechatMessageBuilder } from '../builders/LivechatMessageBuilder'; +import { MessageBuilder } from '../builders/MessageBuilder'; +import { RoomBuilder } from '../builders/RoomBuilder'; +import { UserBuilder } from '../builders/UserBuilder'; +import type { AppVideoConference } from '../builders/VideoConferenceBuilder'; +import { VideoConferenceBuilder } from '../builders/VideoConferenceBuilder'; +import { formatErrorResponse } from '../formatResponseErrorHandler'; + +export class ModifyCreator implements IModifyCreator { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + getLivechatCreator(): ILivechatCreator { + return new Proxy( + { __kind: 'getLivechatCreator' }, + { + get: (_target: unknown, prop: string) => { + // It's not worthwhile to make an asynchronous request for such a simple method + if (prop === 'createToken') { + return () => randomBytes(16).toString('hex'); + } + + if (prop === 'toJSON') { + return () => ({}); + } + + return (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }, + ) as ILivechatCreator; + } + + getUploadCreator(): IUploadCreator { + return new Proxy( + { __kind: 'getUploadCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as IUploadCreator; + } + + getEmailCreator(): IEmailCreator { + return new Proxy( + { __kind: 'getEmailCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getEmailCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as IEmailCreator; + } + + getContactCreator(): IContactCreator { + return new Proxy( + { __kind: 'getContactCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getContactCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as IContactCreator; + } + + getBlockBuilder() { + return new BlockBuilder(); + } + + startMessage(data?: IMessage) { + if (data) { + delete data.id; + } + + return new MessageBuilder(data); + } + + startLivechatMessage(data?: ILivechatMessage) { + if (data) { + delete data.id; + } + + return new LivechatMessageBuilder(data); + } + + startRoom(data?: IRoom) { + if (data) { + // @ts-ignore - this has been imported from the Apps-Engine + delete data.id; + } + + return new RoomBuilder(data); + } + + startDiscussion(data?: Partial) { + if (data) { + delete data.id; + } + + return new DiscussionBuilder(data); + } + + startVideoConference(data?: Partial) { + return new VideoConferenceBuilder(data); + } + + startBotUser(data?: Partial) { + if (data) { + delete data.id; + + const { roles } = data; + + if (roles?.length) { + const hasRole = roles + .map((role: string) => role.toLocaleLowerCase()) + .some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator'); + + if (hasRole) { + throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); + } + } + + if (!data.type) { + data.type = UserType.BOT; + } + } + + return new UserBuilder(data); + } + + public finish( + builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, + ): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.LIVECHAT_MESSAGE: + return this._finishLivechatMessage(builder as ILivechatMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + case RocketChatAssociationModel.DISCUSSION: + return this._finishDiscussion(builder as DiscussionBuilder); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this._finishVideoConference(builder as IVideoConferenceBuilder); + case RocketChatAssociationModel.USER: + return this._finishUser(builder as IUserBuilder); + default: + throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + delete result.id; + + if (!result.sender?.id) { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: ['APP_ID'], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const appUser = response.result; + + if (!appUser) { + throw new Error('Invalid sender assigned to the message.'); + } + + result.sender = appUser as IUser; + } + + if (result.blocks?.length) { + // Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where? + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doCreate', + params: [result, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { + if (builder.getSender() && !builder.getVisitor()) { + return this._finishMessage(builder.getMessageBuilder()); + } + + const result = builder.getMessage(); + delete result.id; + + if (!result.token && !result.visitor?.token) { + throw new Error('Invalid visitor sending the message'); + } + + result.token = result.visitor ? result.visitor.token : result.token; + + const response = await this.senderFn({ + method: 'bridges:getLivechatBridge:doCreateMessage', + params: [result, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + delete result.id; + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator?.id) { + throw new Error('Invalid creator assigned to the room.'); + } + } + + if (result.type !== RoomType.DIRECT_MESSAGE) { + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishDiscussion(builder: DiscussionBuilder): Promise { + const room = builder.getRoom(); + + delete room.id; + + if (!room.creator?.id) { + throw new Error('Invalid creator assigned to the discussion.'); + } + + if (!room.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the discussion.'); + } + + if (!room.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the discussion.'); + } + + if (!room.parentRoom?.id) { + throw new Error('Invalid parentRoom assigned to the discussion.'); + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreateDiscussion', + params: [room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise { + const videoConference = builder.getVideoConference(); + + if (!videoConference.createdBy) { + throw new Error('Invalid creator assigned to the video conference.'); + } + + if (!videoConference.providerName?.trim()) { + throw new Error('Invalid provider name assigned to the video conference.'); + } + + if (!videoConference.rid) { + throw new Error('Invalid roomId assigned to the video conference.'); + } + + const response = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doCreate', + params: [videoConference, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishUser(builder: IUserBuilder): Promise { + const user = builder.getUser(); + + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doCreate', + params: [user, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyExtender.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyExtender.ts new file mode 100644 index 0000000000000..e854465d90f77 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyExtender.ts @@ -0,0 +1,101 @@ +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender'; +import type { IModifyExtender } from '@rocket.chat/apps-engine/definition/accessors/IModifyExtender'; +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender'; +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import type * as Messenger from '../../messenger'; +import { MessageExtender } from '../extenders/MessageExtender'; +import { RoomExtender } from '../extenders/RoomExtender'; +import { VideoConferenceExtender } from '../extenders/VideoConferenceExtend'; +import { formatErrorResponse } from '../formatResponseErrorHandler'; + +export class ModifyExtender implements IModifyExtender { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + public async extendMessage(messageId: string, updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const msg = result.result as IMessage; + + msg.editor = updater; + msg.editedAt = new Date(); + + return new MessageExtender(msg); + } + + public async extendRoom(roomId: string, _updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const room = result.result as IRoom; + + room.updatedAt = new Date(); + + return new RoomExtender(room); + } + + public async extendVideoConference(id: string): Promise { + const result = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doGetById', + params: [id, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const call = result.result as VideoConference; + + call._updatedAt = new Date(); + + return new VideoConferenceExtender(call); + } + + public async finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise { + switch (extender.kind) { + case RocketChatAssociationModel.MESSAGE: + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [(extender as IMessageExtender).getMessage(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + case RocketChatAssociationModel.ROOM: + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [ + (extender as IRoomExtender).getRoom(), + (extender as IRoomExtender).getUsernamesOfMembersBeingAdded(), + AppObjectRegistry.get('id'), + ], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + case RocketChatAssociationModel.VIDEO_CONFERENCE: + await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [(extender as IVideoConferenceExtender).getVideoConference(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + default: + throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); + } + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts new file mode 100644 index 0000000000000..75b92e2001281 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts @@ -0,0 +1,164 @@ +import { UIHelper } from '@rocket.chat/apps/dist/server/misc/UIHelper'; +import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors/ILivechatUpdater'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; +import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater'; +import type { IModifyUpdater } from '@rocket.chat/apps-engine/definition/accessors/IModifyUpdater'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; +import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.js'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import type * as Messenger from '../../messenger'; +import { MessageBuilder } from '../builders/MessageBuilder'; +import { RoomBuilder } from '../builders/RoomBuilder'; +import { formatErrorResponse } from '../formatResponseErrorHandler'; + +export class ModifyUpdater implements IModifyUpdater { + private readonly livechatUpdater: ILivechatUpdater; + + private readonly userUpdater: IUserUpdater; + + private readonly messageUpdater: IMessageUpdater; + + constructor(private readonly senderFn: typeof Messenger.sendRequest) { + this.livechatUpdater = this.proxify('getLivechatUpdater'); + this.userUpdater = this.proxify('getUserUpdater'); + this.messageUpdater = this.proxify('getMessageUpdater'); + } + + private proxify( + target: 'getLivechatUpdater' | 'getUserUpdater' | 'getMessageUpdater', + ): T { + return new Proxy( + { __kind: target }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:${target}:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as T; + } + + public getLivechatUpdater(): ILivechatUpdater { + return this.livechatUpdater; + } + + public getUserUpdater(): IUserUpdater { + return this.userUpdater; + } + + public getMessageUpdater(): IMessageUpdater { + return this.messageUpdater; + } + + public async message(messageId: string, editor: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const builder = new MessageBuilder(response.result as IMessage); + + builder.setEditor(editor); + + return builder; + } + + public async room(roomId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return new RoomBuilder(response.result as IRoom); + } + + public finish(builder: IMessageBuilder | IRoomBuilder): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as MessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as RoomBuilder); + default: + throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); + } + } + + private async _finishMessage(builder: MessageBuilder): Promise { + const result = builder.getMessage(); + + if (!result.id) { + throw new Error("Invalid message, can't update a message without an id."); + } + + if (!result.sender?.id) { + throw new Error('Invalid sender assigned to the message.'); + } + + if (result.blocks?.length) { + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + const changes = { id: result.id, ...builder.getChanges() }; + + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [changes, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + } + + private async _finishRoom(builder: RoomBuilder): Promise { + const room = builder.getRoom(); + + if (!room.id) { + throw new Error("Invalid room, can't update a room without an id."); + } + + if (!room.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (room.type !== RoomType.LIVE_CHAT) { + if (!room.creator?.id) { + throw new Error('Invalid creator assigned to the room.'); + } + + if (!room.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!room.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + + const changes = { id: room.id, ...builder.getChanges() }; + + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [changes, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/notifier.ts b/packages/apps/base-runtime/src/lib/accessors/notifier.ts new file mode 100644 index 0000000000000..d56d173de5293 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/notifier.ts @@ -0,0 +1,80 @@ +import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import { TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageBuilder } from './builders/MessageBuilder'; +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import type * as Messenger from '../messenger'; +import { formatErrorResponse } from './formatResponseErrorHandler'; + +export class Notifier implements INotifier { + private senderFn: typeof Messenger.sendRequest; + + constructor(senderFn: typeof Messenger.sendRequest) { + this.senderFn = senderFn; + } + + public async notifyUser(user: IUser, message: IMessage): Promise { + if (!message.sender?.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get('id')]); + } + + public async notifyRoom(room: IRoom, message: IMessage): Promise { + if (!message.sender?.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyRoom', [room, message, AppObjectRegistry.get('id')]); + } + + public async typing(options: ITypingOptions): Promise<() => Promise> { + options.scope = options.scope || TypingScope.Room; + + if (!options.username) { + const appUser = await this.getAppUser(); + options.username = (appUser && appUser.name) || ''; + } + + const appId = AppObjectRegistry.get('id'); + + await this.callMessageBridge('doTyping', [{ ...options, isTyping: true }, appId]); + + return async () => { + await this.callMessageBridge('doTyping', [{ ...options, isTyping: false }, appId]); + }; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(); + } + + private async callMessageBridge(method: string, params: Array): Promise { + await this.senderFn({ + method: `bridges:getMessageBridge:${method}`, + params, + }).catch((err) => { + throw formatErrorResponse(err); + }); + } + + private async getAppUser(): Promise { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: [AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return response.result as IUser | undefined; + } +} diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts new file mode 100644 index 0000000000000..a41891315685d --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/AppAccessors.test.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- acceptable in this test file */ +/* eslint-disable testing-library/no-await-sync-queries */ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it } from 'node:test'; + +import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; +import type { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { AppAccessors } from '../mod'; + +describe('AppAccessors', () => { + let appAccessors: AppAccessors; + + const senderFn = (r: object) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + appAccessors = new AppAccessors(senderFn); + AppObjectRegistry.clear(); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('creates the correct format for IRead calls', async () => { + const roomRead = appAccessors.getReader()!.getRoomReader(); + const room = await roomRead.getById('123'); + + assert.deepStrictEqual(room, { + params: ['123'], + method: 'accessor:getReader:getRoomReader:getById', + }); + }); + + it('creates the correct format for IEnvironmentRead calls from IRead', async () => { + const reader = appAccessors.getReader()!.getEnvironmentReader().getEnvironmentVariables(); + const room = await reader.getValueByName('NODE_ENV'); + + assert.deepStrictEqual(room, { + params: ['NODE_ENV'], + method: 'accessor:getReader:getEnvironmentReader:getEnvironmentVariables:getValueByName', + }); + }); + + it('creates the correct format for IEvironmentRead calls', async () => { + const envRead = appAccessors.getEnvironmentRead(); + const env = await envRead.getServerSettings().getValueById('123'); + + assert.deepStrictEqual(env, { + params: ['123'], + method: 'accessor:getEnvironmentRead:getServerSettings:getValueById', + }); + }); + + it('creates the correct format for IEvironmentWrite calls', async () => { + const envRead = appAccessors.getEnvironmentWrite(); + const env = await envRead.getServerSettings().incrementValue('123', 6); + + assert.deepStrictEqual(env, { + params: ['123', 6], + method: 'accessor:getEnvironmentWrite:getServerSettings:incrementValue', + }); + }); + + it('creates the correct format for IConfigurationModify calls', async () => { + const configModify = appAccessors.getConfigurationModify(); + const command = await configModify.slashCommands.modifySlashCommand({ + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise { + throw new Error('Function not implemented.'); + }, + }); + + // The function will not be serialized and sent to the main process + delete (command as any).params[0].executor; + + assert.deepStrictEqual(command, { + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', + }); + }); + + it('correctly stores a reference to a slashcommand object and sends a request via proxy', async () => { + const configExtend = appAccessors.getConfigurationExtend(); + + const slashcommand = { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + executor() { + return Promise.resolve(); + }, + }; + + const result = await configExtend.slashCommands.provideSlashCommand(slashcommand); + + assert.deepStrictEqual(AppObjectRegistry.get('slashcommand:test'), slashcommand); + + // The function will not be serialized and sent to the main process + delete (result as any).params[0].executor; + + assert.deepStrictEqual(result, { + method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts new file mode 100644 index 0000000000000..44eb9a169ab93 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyCreator.test.ts @@ -0,0 +1,276 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it, mock } from 'node:test'; + +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUploadDescriptor } from '@rocket.chat/apps-engine/definition/uploads/IUploadDescriptor'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { ModifyCreator } from '../modify/ModifyCreator'; + +describe('ModifyCreator', () => { + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('sends the correct payload in the request to create a message', async () => { + const spying = mock.fn(senderFn); + const modifyCreator = new ModifyCreator(spying); + const messageBuilder = modifyCreator.startMessage(); + + // Importing types from the Apps-Engine is problematic, so we'll go with `any` here + messageBuilder + .setRoom({ id: '123' } as IRoom) + .setSender({ id: '456' } as IUser) + .setText('Hello World') + .setUsernameAlias('alias') + .setAvatarUrl('https://avatars.com/123'); + + // We can't get a legitimate return value here, so we ignore it + // but we need to know that the request sent was well formed + await modifyCreator.finish(messageBuilder); + + assert.deepStrictEqual(spying.mock.calls[0].arguments, [ + { + method: 'bridges:getMessageBridge:doCreate', + params: [ + { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }, + 'deno-test', + ], + }, + ]); + }); + + it('sends the correct payload in the request to upload a buffer', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = await modifyCreator + .getUploadCreator() + .uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile' } as IUploadDescriptor); + + assert.deepStrictEqual(result, { + method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', + params: [Buffer.from([1, 2, 3, 4]), { filename: 'testfile' }], + }); + }); + + it('sends the correct payload in the request to create a visitor', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = (await modifyCreator.getLivechatCreator().createVisitor({ + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + })) as any; // We modified the send function so it changed the original return type of the function + + assert.deepStrictEqual(result, { + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + }); + }); + + // This test is important because if we return a promise we break API compatibility + it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = modifyCreator.getLivechatCreator().createToken(); + + assert.ok(!((result as any) instanceof Promise)); + assert.ok(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`); + }); + + it('throws an error when a proxy method of getLivechatCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Test error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assert.rejects( + () => + livechatCreator.createAndReturnVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + { message: 'Test error' }, + ); + }); + + it('throws an instance of Error when getLivechatCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Livechat method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assert.rejects( + () => + livechatCreator.createVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + { message: 'Livechat method error' }, + ); + }); + + it('throws a default Error when getLivechatCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assert.rejects( + () => + livechatCreator.createVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + { message: 'An unknown error occurred' }, + ); + }); + + it('throws an error when a proxy method of getUploadCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Upload error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assert.rejects( + () => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor), + { + message: 'Upload error', + }, + ); + }); + + it('throws an instance of Error when getUploadCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Upload method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assert.rejects( + () => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor), + { + message: 'Upload method error', + }, + ); + }); + + it('throws a default Error when getUploadCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assert.rejects( + () => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor), + { + message: 'An unknown error occurred', + }, + ); + }); + + it('throws an error when a proxy method of getEmailCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Email error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assert.rejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + { message: 'Email error' }, + ); + }); + + it('throws an instance of Error when getEmailCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Email method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assert.rejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + { message: 'Email method error' }, + ); + }); + + it('throws a default Error when getEmailCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assert.rejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + { message: 'An unknown error occurred' }, + ); + }); + + it('throws an error when a proxy method of getContactCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Contact creation error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), { + message: 'Contact creation error', + }); + }); + + it('throws an instance of Error when getContactCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Contact creation error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), { + message: 'Contact creation error', + }); + }); + + it('throws a default Error when getContactCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), { + message: 'An unknown error occurred', + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyExtender.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyExtender.test.ts new file mode 100644 index 0000000000000..00b53ea64dfc3 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyExtender.test.ts @@ -0,0 +1,235 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it, mock } from 'node:test'; + +import jsonrpc from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { ModifyExtender } from '../modify/ModifyExtender'; + +describe('ModifyExtender', () => { + let extender: ModifyExtender; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + extender = new ModifyExtender(senderFn); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the extend message requests', async () => { + const _spy = mock.method(extender, 'senderFn' as any); + + const messageExtender = await extender.extendMessage('message-id', { _id: 'user-id' } as any); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['message-id', 'deno-test'], + }, + ]); + + messageExtender.addCustomField('key', 'value'); + + await extender.finish(messageExtender); + + assert.deepStrictEqual(_spy.mock.calls[1].arguments, [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [messageExtender.getMessage(), 'deno-test'], + }, + ]); + + _spy.mock.restore(); + }); + + it('correctly formats requests for the extend room requests', async () => { + const _spy = mock.method(extender, 'senderFn' as any); + + const roomExtender = await extender.extendRoom('room-id', { _id: 'user-id' } as any); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['room-id', 'deno-test'], + }, + ]); + + roomExtender.addCustomField('key', 'value'); + + await extender.finish(roomExtender); + + assert.deepStrictEqual(_spy.mock.calls[1].arguments, [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [roomExtender.getRoom(), [], 'deno-test'], + }, + ]); + + _spy.mock.restore(); + }); + + it('correctly formats requests for the extend video conference requests', async () => { + const _spy = mock.method(extender, 'senderFn' as any); + + const videoConferenceExtender = await extender.extendVideoConference('video-conference-id'); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getVideoConferenceBridge:doGetById', + params: ['video-conference-id', 'deno-test'], + }, + ]); + + videoConferenceExtender.setStatus(4); + + await extender.finish(videoConferenceExtender); + + assert.deepStrictEqual(_spy.mock.calls[1].arguments, [ + { + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [videoConferenceExtender.getVideoConference(), 'deno-test'], + }, + ]); + + _spy.mock.restore(); + }); + + describe('Error Handling', () => { + describe('extendMessage', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + extender, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), { + message: 'An unknown error occurred', + }); + + _stub.mock.restore(); + }); + }); + + describe('extendRoom', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + extender, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + }); + + describe('extendVideoConference', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + extender, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + }); + + describe('finish', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + extender, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), { + message: 'An unknown error occurred', + }); + + _stub.mock.restore(); + }); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts new file mode 100644 index 0000000000000..bebf586878def --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/ModifyUpdater.test.ts @@ -0,0 +1,238 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it, mock } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import jsonrpc from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import type { RoomBuilder } from '../builders/RoomBuilder'; +import { ModifyUpdater } from '../modify/ModifyUpdater'; + +describe('ModifyUpdater', () => { + let modifyUpdater: ModifyUpdater; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + modifyUpdater = new ModifyUpdater(senderFn); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the update message flow', async () => { + // `as any` because it's hard to align the types for a private prop + const _spy = mock.method(modifyUpdater, 'senderFn' as any); + + const messageBuilder = await modifyUpdater.message('123', { id: '456' } as IUser); + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['123', 'deno-test'], + }, + ]); + + messageBuilder.setUpdateData( + { + id: '123', + room: { id: '123' } as IRoom, + sender: { id: '456' } as IUser, + text: 'Hello World', + } as IMessage, + { id: '456' } as IUser, + ); + + await modifyUpdater.finish(messageBuilder); + + assert.deepStrictEqual(_spy.mock.calls[1].arguments, [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [{ id: '123', ...(messageBuilder as any).getChanges() }, 'deno-test'], + }, + ]); + + _spy.mock.restore(); + }); + + it('correctly formats requests for the update room flow', async () => { + const _spy = mock.method(modifyUpdater, 'senderFn' as any); + + const roomBuilder = (await modifyUpdater.room('123', { id: '456' } as IUser)) as RoomBuilder; + + assert.deepStrictEqual(_spy.mock.calls[0].arguments, [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['123', 'deno-test'], + }, + ]); + + roomBuilder.setData({ + id: '123', + type: 'c', + displayName: 'Test Room', + slugifiedName: 'test-room', + creator: { id: '456' }, + } as IRoom); + + roomBuilder.setMembersToBeAddedByUsernames(['username1', 'username2']); + + // We need to sneak in the id as the `modifyUpdater.room` call won't have legitimate data + roomBuilder.getRoom().id = '123'; + + await modifyUpdater.finish(roomBuilder); + + assert.deepStrictEqual(_spy.mock.calls[1].arguments, [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [{ id: '123', ...roomBuilder.getChanges() }, roomBuilder.getMembersToBeAddedUsernames(), 'deno-test'], + }, + ]); + }); + + it('correctly formats requests to UserUpdater methods', async () => { + const result = (await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as IUser, 'Hello World')) as any; + + assert.deepStrictEqual(result, { + method: 'accessor:getModifier:getUpdater:getUserUpdater:updateStatusText', + params: [{ id: '123' }, 'Hello World'], + }); + }); + + it('correctly formats requests to LivechatUpdater methods', async () => { + const result = (await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as IRoom, 'close it!')) as any; + + assert.deepStrictEqual(result, { + method: 'accessor:getModifier:getUpdater:getLivechatUpdater:closeRoom', + params: [{ id: '123' }, 'close it!'], + }); + }); + + it('correctly formats requests to MessageUpdater methods', async () => { + const result = (await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:')) as any; + + assert.deepStrictEqual(result, { + method: 'accessor:getModifier:getUpdater:getMessageUpdater:addReaction', + params: ['message-id', 'user-id', ':smile:'], + }); + }); + + describe('Error Handling', () => { + describe('message', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + modifyUpdater, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), { + message: 'An unknown error occurred', + }); + + _stub.mock.restore(); + }); + }); + + describe('room', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + modifyUpdater, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + }); + + describe('finish', () => { + const messageUpdater = { + kind: 'message', + getMessage: () => ({ + id: 'message-id', + sender: { id: 'sender-id' }, + }), + getChanges: () => ({ + id: 'message-id', + sender: { id: 'sender-id' }, + }), + } as any; + + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any); + + await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = mock.method( + modifyUpdater, + 'senderFn' as any, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'unit-test-error' }); + + _stub.mock.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any); + + await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/formatResponseErrorHandler.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/formatResponseErrorHandler.test.ts new file mode 100644 index 0000000000000..231da32a44151 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/formatResponseErrorHandler.test.ts @@ -0,0 +1,211 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import * as jsonrpc from 'jsonrpc-lite'; + +import { formatErrorResponse } from '../formatResponseErrorHandler'; + +describe('formatErrorResponse', () => { + describe('JSON-RPC ErrorObject handling', () => { + it('formats ErrorObject instances correctly', () => { + const errorObject = jsonrpc.error('test-id', new jsonrpc.JsonRpcError('Test error message', 1000)); + const result = formatErrorResponse(errorObject); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'Test error message'); + }); + + it('formats objects with error.message structure', () => { + const errorLikeObject = { + error: { + message: 'Custom error message', + code: 404, + }, + }; + const result = formatErrorResponse(errorLikeObject); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'Custom error message'); + }); + + it('handles nested error objects with complex structure', () => { + const complexError = { + error: { + message: 'Database connection failed', + details: { + host: 'localhost', + port: 5432, + }, + }, + id: 'req-123', + }; + const result = formatErrorResponse(complexError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'Database connection failed'); + }); + + it('handles error objects with empty message', () => { + const emptyMessageError = { + error: { + message: '', + code: 500, + }, + }; + const result = formatErrorResponse(emptyMessageError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, ''); + }); + }); + + describe('Error instance passthrough', () => { + it('returns existing Error instances unchanged', () => { + const originalError = new Error('Original error message'); + const result = formatErrorResponse(originalError); + + assert.strictEqual(result, originalError); + assert.deepStrictEqual(result.message, 'Original error message'); + }); + + it('returns custom Error subclasses unchanged', () => { + class CustomError extends Error { + constructor( + message: string, + public code: number, + ) { + super(message); + this.name = 'CustomError'; + } + } + + const customError = new CustomError('Custom error', 404); + const result = formatErrorResponse(customError); + + assert.strictEqual(result, customError); + assert.deepStrictEqual(result.message, 'Custom error'); + assert.deepStrictEqual((result as CustomError).code, 404); + }); + + it('handles Error instances with additional properties', () => { + const errorWithProps = new Error('Error with props') as any; + errorWithProps.statusCode = 500; + errorWithProps.details = { reason: 'timeout' }; + + const result = formatErrorResponse(errorWithProps); + + assert.strictEqual(result, errorWithProps); + assert.deepStrictEqual(result.message, 'Error with props'); + assert.deepStrictEqual((result as any).statusCode, 500); + }); + }); + + describe('Unknown error handling', () => { + it('wraps string errors with default message and cause', () => { + const stringError = 'Simple string error'; + const result = formatErrorResponse(stringError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, stringError); + }); + + it('wraps number errors with default message and cause', () => { + const numberError = 404; + const result = formatErrorResponse(numberError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, numberError); + }); + + it('wraps boolean errors with default message and cause', () => { + const booleanError = false; + const result = formatErrorResponse(booleanError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, booleanError); + }); + + it('wraps null with default message and cause', () => { + const result = formatErrorResponse(null); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, null); + }); + + it('wraps undefined with default message and cause', () => { + const result = formatErrorResponse(undefined); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, undefined); + }); + + it('wraps arrays with default message and cause', () => { + const arrayError = ['error', 'details']; + const result = formatErrorResponse(arrayError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, arrayError); + }); + + it('wraps functions with default message and cause', () => { + const functionError = () => 'error'; + const result = formatErrorResponse(functionError); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, functionError); + }); + + it('wraps plain objects without error.message with default message and cause', () => { + const plainObject = { + status: 'failed', + reason: 'timeout', + data: { id: 123 }, + }; + const result = formatErrorResponse(plainObject); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, plainObject); + }); + + it('wraps objects with error property but no message with default message and cause', () => { + const errorObjectNoMessage = { + error: { + code: 500, + details: 'Internal server error', + }, + }; + const result = formatErrorResponse(errorObjectNoMessage); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + assert.deepStrictEqual(result.cause, errorObjectNoMessage); + }); + }); + + it('ensures all returned values are proper Error instances', () => { + const testCases = ['string error', 123, null, undefined, { error: { message: 'test' } }, new Error('test'), { plain: 'object' }]; + + for (const testCase of testCases) { + const result = formatErrorResponse(testCase); + assert.ok(result instanceof Error, `Failed for input: ${JSON.stringify(testCase)}`); + } + }); + + it('prevents "[object Object]" error messages for plain objects', () => { + const plainObject = { status: 'error', code: 500 }; + const result = formatErrorResponse(plainObject); + + assert.ok(result instanceof Error, `Expected instance of Error`); + assert.deepStrictEqual(result.message, 'An unknown error occurred'); + // Ensure the message is not "[object Object]" + assert.deepStrictEqual((result.message as string) !== '[object Object]', true); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts b/packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts new file mode 100644 index 0000000000000..e586bb6e24cbe --- /dev/null +++ b/packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts @@ -0,0 +1,156 @@ +import * as assert from 'node:assert'; +import { beforeEach, describe, it, after, mock } from 'node:test'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry'; +import { Http } from '../http'; + +describe('Http accessor error handling integration', () => { + let http: Http; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'test-app-id'); + + const mockHttpExtend = { + getDefaultHeaders: () => new Map(), + getDefaultParams: () => new Map(), + getPreRequestHandlers: () => [], + getPreResponseHandlers: () => [], + }; + + const mockRead = {}; + const mockPersistence = {}; + + http = new Http(mockRead as any, mockPersistence as any, mockHttpExtend as any, () => Promise.resolve({}) as any); + }); + + after(() => { + AppObjectRegistry.clear(); + }); + + describe('HTTP method error handling', () => { + it('formats JSON-RPC errors correctly for GET requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => + Promise.reject({ + error: { + message: 'HTTP GET request failed', + code: 404, + }, + }), + ); + + await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'HTTP GET request failed' }); + + _stub.mock.restore(); + }); + + it('formats JSON-RPC errors correctly for POST requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => + Promise.reject({ + error: { + message: 'HTTP POST request validation failed', + code: 400, + }, + }), + ); + + await assert.rejects(() => http.post('https://api.example.com/create', { data: { name: 'test' } }), { + message: 'HTTP POST request validation failed', + }); + + _stub.mock.restore(); + }); + + it('formats JSON-RPC errors correctly for PUT requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => + Promise.reject({ + error: { + message: 'HTTP PUT request unauthorized', + code: 401, + }, + }), + ); + + await assert.rejects(() => http.put('https://api.example.com/update/123', { data: { name: 'updated' } }), { + message: 'HTTP PUT request unauthorized', + }); + + _stub.mock.restore(); + }); + + it('formats JSON-RPC errors correctly for DELETE requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => + Promise.reject({ + error: { + message: 'HTTP DELETE request forbidden', + code: 403, + }, + }), + ); + + await assert.rejects(() => http.del('https://api.example.com/delete/123'), { message: 'HTTP DELETE request forbidden' }); + + _stub.mock.restore(); + }); + + it('formats JSON-RPC errors correctly for PATCH requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => + Promise.reject({ + error: { + message: 'HTTP PATCH request conflict', + code: 409, + }, + }), + ); + + await assert.rejects(() => http.patch('https://api.example.com/patch/123', { data: { status: 'active' } }), { + message: 'HTTP PATCH request conflict', + }); + + _stub.mock.restore(); + }); + }); + + describe('Error instance passthrough', () => { + it('passes through existing Error instances unchanged for HTTP requests', async () => { + const originalError = new Error('Network timeout error'); + const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(originalError)); + + await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'Network timeout error' }); + + _stub.mock.restore(); + }); + }); + + describe('Unknown error handling', () => { + it('wraps unknown object errors with default message for HTTP requests', async () => { + const unknownError = { + status: 'failed', + details: 'Something went wrong', + timestamp: Date.now(), + }; + const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(unknownError)); + + await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + + it('wraps string errors with default message for HTTP requests', async () => { + const stringError = 'Connection refused'; + const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(stringError)); + + await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + + it('wraps null/undefined errors with default message for HTTP requests', async () => { + const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(null)); + + await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' }); + + _stub.mock.restore(); + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/ast/mod.ts b/packages/apps/base-runtime/src/lib/ast/mod.ts new file mode 100644 index 0000000000000..84e340c77d412 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/ast/mod.ts @@ -0,0 +1,69 @@ +import type { Node, AnyNode } from 'acorn'; +import { parse } from 'acorn'; +import { fullAncestor } from 'acorn-walk'; +import { generate } from 'astring'; + +import * as operations from './operations'; +import type { WalkerState } from './operations'; + +function fixAst(ast: Node): boolean { + const pendingOperations = [ + operations.fixLivechatIsOnlineCalls, + operations.checkReassignmentOfModifiedIdentifiers, + operations.fixRoomUsernamesCalls, + ]; + + // Have we touched the tree? + let isModified = false; + + while (pendingOperations.length) { + const ops = pendingOperations.splice(0); + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + }; + + fullAncestor( + ast, + (node, state, ancestors, type) => { + ops.forEach((operation) => operation(node as AnyNode, state as WalkerState, ancestors as AnyNode[], type)); + }, + undefined, + state, + ); + + if (state.isModified) { + isModified = true; + } + + if (state.functionIdentifiers.size) { + pendingOperations.push( + operations.buildFixModifiedFunctionsOperation(state.functionIdentifiers), + operations.checkReassignmentOfModifiedIdentifiers, + ); + } + } + + return isModified; +} + +export function fixBrokenSynchronousAPICalls(appSource: string): string { + const astRootNode = parse(appSource, { + // Latest ecma version supported by this version of acorn. + ecmaVersion: 'latest', + // Allow everything, we don't want to complain if code is badly written + // Also, since the code itself has been transpiled, the chance of getting + // shenanigans is lower + allowReserved: true, + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + allowSuperOutsideMethod: true, + }); + + if (fixAst(astRootNode)) { + return generate(astRootNode); + } + + return appSource; +} diff --git a/packages/apps/base-runtime/src/lib/ast/operations.ts b/packages/apps/base-runtime/src/lib/ast/operations.ts new file mode 100644 index 0000000000000..bb9bdfbeca355 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/ast/operations.ts @@ -0,0 +1,237 @@ +import type { AnyNode, AssignmentExpression, Function, AwaitExpression, Expression, Identifier, MethodDefinition, Property } from 'acorn'; +import type { FullAncestorWalkerCallback } from 'acorn-walk'; + +export type WalkerState = { + isModified: boolean; + functionIdentifiers: Set; +}; + +export function getFunctionIdentifier(ancestors: AnyNode[], functionNodeIndex: number) { + const parent = ancestors[functionNodeIndex - 1]; + + // If there is a parent node and it's not a computed property, we can try to + // extract an identifier for our function from it. This needs to be done first + // because when functions are assigned to named symbols, this will be the only + // way to call it, even if the function itself has an identifier + // Consider the following block: + // + // const foo = function bar() {} + // + // Even though the function itself has a name, the only way to call it in the + // program is wiht `foo()` + if (parent && !(parent as Property | MethodDefinition).computed) { + // Several node types can have an id prop of type Identifier + const { id } = parent as unknown as { id?: Identifier }; + if (id?.type === 'Identifier') { + return id.name; + } + + // Usually assignments to object properties (MethodDefinition, Property) + const { key } = parent as MethodDefinition | Property; + if (key?.type === 'Identifier') { + return key.name; + } + + // Variable assignments have left hand side that can be used as Identifier + const { left } = parent as AssignmentExpression; + + // Simple assignment: `const fn = () => {}` + if (left?.type === 'Identifier') { + return left.name; + } + + // Object property assignment: `obj.fn = () => {}` + if (left?.type === 'MemberExpression' && !left.computed) { + return (left.property as Identifier).name; + } + } + + // nodeIndex needs to be the index of a Function node (either FunctionDeclaration or FunctionExpression) + const currentNode = ancestors[functionNodeIndex] as Function; + + // Function declarations or expressions can be directly named + if (currentNode.id?.type === 'Identifier') { + return currentNode.id.name; + } +} + +export function wrapWithAwait(node: Expression) { + if (!node.type.endsWith('Expression')) { + throw new Error(`Can't wrap "${node.type}" with await`); + } + + const innerNode: Expression = { ...node }; + + node.type = 'AwaitExpression'; + // starting here node has become an AwaitExpression + (node as AwaitExpression).argument = innerNode; + + Object.keys(node).forEach((key) => !['type', 'argument'].includes(key) && delete node[key as keyof AnyNode]); +} + +export function asyncifyScope(ancestors: AnyNode[], state: WalkerState) { + const functionNodeIndex = ancestors.findLastIndex((n) => 'async' in n); + if (functionNodeIndex === -1) return; + + // At this point this is a node with an "async" property, so it has to be + // of type Function - let TS know about that + const functionScopeNode = ancestors[functionNodeIndex] as Function; + + if (functionScopeNode.async) { + return; + } + + functionScopeNode.async = true; + + // If the parent of a function node is a call expression, we're talking about an IIFE + // Should we care about this case as well? + // const parentNode = ancestors[functionScopeIndex-1]; + // if (parentNode?.type === 'CallExpression' && ancestors[functionScopeIndex-2] && ancestors[functionScopeIndex-2].type !== 'AwaitExpression') { + // pendingOperations.push(buildFunctionPredicate(getFunctionIdentifier(ancestors, functionScopeIndex-2))); + // } + + const identifier = getFunctionIdentifier(ancestors, functionNodeIndex); + + // We can't fix calls of functions which name we can't determine at compile time + if (!identifier) return; + + state.functionIdentifiers.add(identifier); +} + +export function buildFixModifiedFunctionsOperation(functionIdentifiers: Set): FullAncestorWalkerCallback { + return function _fixModifiedFunctionsOperation(node, state, ancestors) { + if (node.type !== 'CallExpression') return; + + // This node is a simple call to a function, like `fn()` + let isWrappable = node.callee.type === 'Identifier' && functionIdentifiers.has(node.callee.name); + + // This node is a call to an object property or instance method, like `obj.fn()`, but not computed like `obj[fn]()` + isWrappable ||= + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property?.type === 'Identifier' && + functionIdentifiers.has(node.callee.property.name); + + // This is a weird dereferencing technique used by bundlers, and since we'll be dealing with bundled sources we have to check for it + // e.g. `r=(0,fn)(e)` + if (!isWrappable && node.callee.type === 'SequenceExpression') { + const [, secondExpression] = node.callee.expressions; + isWrappable = secondExpression?.type === 'Identifier' && functionIdentifiers.has(secondExpression.name); + isWrappable ||= + secondExpression?.type === 'MemberExpression' && + !secondExpression.computed && + secondExpression.property.type === 'Identifier' && + functionIdentifiers.has(secondExpression.property.name); + } + + if (!isWrappable) return; + + // ancestors[ancestors.length-1] === node, so here we're checking for parent node + const parentNode = ancestors[ancestors.length - 2]; + if (!parentNode || parentNode.type === 'AwaitExpression') return; + + wrapWithAwait(node); + asyncifyScope(ancestors, state); + + state.isModified = true; + }; +} + +export const checkReassignmentOfModifiedIdentifiers: FullAncestorWalkerCallback = ( + node, + { functionIdentifiers }, + _ancestors, +) => { + if (node.type === 'AssignmentExpression') { + if (node.operator !== '=') return; + + let identifier = ''; + + if (node.left.type === 'Identifier') identifier = node.left.name; + + if (node.left.type === 'MemberExpression' && !node.left.computed) { + identifier = (node.left.property as Identifier).name; + } + + if (!identifier || node.right.type !== 'Identifier' || !functionIdentifiers.has(node.right.name)) return; + + functionIdentifiers.add(identifier); + + return; + } + + if (node.type === 'VariableDeclarator') { + if (node.id.type !== 'Identifier' || functionIdentifiers.has(node.id.name)) return; + + if (node.init?.type !== 'Identifier' || !functionIdentifiers.has(node.init?.name)) return; + + functionIdentifiers.add(node.id.name); + + return; + } + + // "Property" is for plain objects, "PropertyDefinition" is for classes + // but both share the same structure + if (node.type === 'Property' || node.type === 'PropertyDefinition') { + if (node.key.type !== 'Identifier' || functionIdentifiers.has(node.key.name)) return; + + if (node.value?.type !== 'Identifier' || !functionIdentifiers.has(node.value.name)) return; + + functionIdentifiers.add(node.key.name); + } +}; + +export const fixLivechatIsOnlineCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'isOnline') return; + + if (node.object.type !== 'CallExpression') return; + + if (node.object.callee.type !== 'MemberExpression') return; + + if ((node.object.callee.property as Identifier).name !== 'getLivechatReader') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + // If we're in the middle of a chained member access, we can't wrap with await + if (ancestors[parentIndex].type === 'MemberExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +}; + +export const fixRoomUsernamesCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'usernames') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +}; diff --git a/packages/apps/base-runtime/src/lib/ast/tests/data/ast_blocks.ts b/packages/apps/base-runtime/src/lib/ast/tests/data/ast_blocks.ts new file mode 100644 index 0000000000000..079c970c44a0d --- /dev/null +++ b/packages/apps/base-runtime/src/lib/ast/tests/data/ast_blocks.ts @@ -0,0 +1,516 @@ +import type { AnyNode, ClassDeclaration, ExpressionStatement, FunctionDeclaration, VariableDeclaration } from 'acorn'; + +/** + * Partial AST blocks to support testing. + * `start` and `end` properties are omitted for brevity. + */ + +type TestNodeExcerpt = { + code: string; + // start/end are omitted from test fixtures for brevity; cast to any to allow partial node objects + node: N; +}; + +const startEnd = { start: 0, end: 0 }; + +export const FunctionDeclarationFoo: TestNodeExcerpt = { + code: 'function foo() {}', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + ...startEnd, + }, + ...startEnd, + }, +}; + +export const ConstFooAssignedFunctionExpression: TestNodeExcerpt = { + code: 'const foo = function() {}', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + init: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, +}; + +export const AssignmentExpressionOfArrowFunctionToFooIdentifier: TestNodeExcerpt = { + code: 'foo = () => {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + right: { + type: 'ArrowFunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, +}; + +export const AssignmentExpressionOfNamedFunctionToFooMemberExpression: TestNodeExcerpt = { + code: 'obj.foo = function bar() {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'a', + ...startEnd, + }, + property: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + computed: false, + optional: false, + ...startEnd, + }, + right: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, +}; + +export const MethodDefinitionOfFooInClassBar: TestNodeExcerpt = { + code: 'class Bar { foo() {} }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'Bar', + ...startEnd, + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'MethodDefinition', + key: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + value: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + ...startEnd, + }, + ...startEnd, + }, + kind: 'method', + computed: false, + static: false, + ...startEnd, + }, + ], + ...startEnd, + }, + ...startEnd, + }, +}; + +export const SimpleCallExpressionOfFoo: TestNodeExcerpt = { + code: 'foo()', + node: { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + arguments: [], + optional: false, + ...startEnd, + }, + ...startEnd, + }, +}; + +export const SyncFunctionDeclarationWithAsyncCallExpression: TestNodeExcerpt = { + // NOTE: this is invalid syntax, it won't be parsed by acorn + // but it can be an intermediary state of the AST after we run + // `wrapWithAwait` on "bar" call expressions, for instance + code: 'function foo() { return () => await bar() }', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'ReturnStatement', + argument: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'AwaitExpression', + argument: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + arguments: [], + optional: false, + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, + ...startEnd, + }, +}; + +export const AssignmentOfFooToBar: TestNodeExcerpt = { + code: 'bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + right: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, +}; + +export const AssignmentOfFooToBarMemberExpression: TestNodeExcerpt = { + code: 'obj.bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + computed: false, + optional: false, + object: { + type: 'Identifier', + name: 'obj', + ...startEnd, + }, + property: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + ...startEnd, + }, + right: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, +}; + +export const AssignmentOfFooToBarVariableDeclarator: TestNodeExcerpt = { + code: 'const bar = foo', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + init: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, +}; + +export const AssignmentOfFooToBarPropertyDefinition: TestNodeExcerpt = { + code: 'class baz { bar = foo }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'baz', + ...startEnd, + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'PropertyDefinition', + static: false, + computed: false, + key: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + value: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, + ...startEnd, + }, +}; + +const fixSimpleCallExpressionCode = ` +function bar() { + const a = foo(); + + return a; +}`; + +export const FixSimpleCallExpression: TestNodeExcerpt = { + code: fixSimpleCallExpressionCode, + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'a', + ...startEnd, + }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + arguments: [], + optional: false, + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, + { + type: 'ReturnStatement', + argument: { + type: 'Identifier', + name: 'a', + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, + ...startEnd, + }, +}; + +export const ArrowFunctionDerefCallExpression: TestNodeExcerpt = { + // NOTE: this call strategy is widely used by bundlers; it's used to sever the `this` + // reference in the method from the object that contains it. This is mostly because + // the bundler wants to ensure that it does not messes up the bindings in the code it + // generates. + // + // This would be similar to doing `foo.call(undefined)` + code: 'const bar = () => (0, e.foo)();', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + ...startEnd, + }, + init: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'CallExpression', + optional: false, + arguments: [], + callee: { + type: 'SequenceExpression', + expressions: [ + { + type: 'Literal', + value: 0, + ...startEnd, + }, + { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'e', + ...startEnd, + }, + property: { + type: 'Identifier', + name: 'foo', + ...startEnd, + }, + computed: false, + optional: false, + ...startEnd, + }, + ], + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ...startEnd, + }, + ], + ...startEnd, + }, +}; diff --git a/packages/apps/base-runtime/src/lib/ast/tests/operations.test.ts b/packages/apps/base-runtime/src/lib/ast/tests/operations.test.ts new file mode 100644 index 0000000000000..576fd72e90b07 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/ast/tests/operations.test.ts @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- acceptable for this test file */ +import * as assert from 'node:assert'; +import { beforeEach, describe, it } from 'node:test'; + +import type { + AnyNode, + ArrowFunctionExpression, + AssignmentExpression, + AwaitExpression, + Expression, + MethodDefinition, + ReturnStatement, + VariableDeclaration, +} from 'acorn'; + +import type { WalkerState } from '../operations'; +import { + asyncifyScope, + buildFixModifiedFunctionsOperation, + checkReassignmentOfModifiedIdentifiers, + getFunctionIdentifier, + wrapWithAwait, +} from '../operations'; +import { + ArrowFunctionDerefCallExpression, + AssignmentExpressionOfArrowFunctionToFooIdentifier, + AssignmentExpressionOfNamedFunctionToFooMemberExpression, + AssignmentOfFooToBar, + AssignmentOfFooToBarMemberExpression, + AssignmentOfFooToBarPropertyDefinition, + AssignmentOfFooToBarVariableDeclarator, + ConstFooAssignedFunctionExpression, + FixSimpleCallExpression, + FunctionDeclarationFoo, + MethodDefinitionOfFooInClassBar, + SimpleCallExpressionOfFoo, + SyncFunctionDeclarationWithAsyncCallExpression, +} from './data/ast_blocks'; + +describe('getFunctionIdentifier', () => { + it(`identifies the name "foo" for the code \`${FunctionDeclarationFoo.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [FunctionDeclarationFoo.node]; + const functionNodeIndex = 0; + assert.deepStrictEqual('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${ConstFooAssignedFunctionExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + ConstFooAssignedFunctionExpression.node, // VariableDeclaration + ConstFooAssignedFunctionExpression.node.declarations[0], // VariableDeclarator + ConstFooAssignedFunctionExpression.node.declarations[0].init!, // FunctionExpression + ]; + const functionNodeIndex = 2; + assert.deepStrictEqual('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfArrowFunctionToFooIdentifier.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfArrowFunctionToFooIdentifier.node, // ExpressionStatement + AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression, // AssignmentExpression + (AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression as AssignmentExpression).right, // ArrowFunctionExpression + ]; + const functionNodeIndex = 2; + assert.deepStrictEqual('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfNamedFunctionToFooMemberExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node, // ExpressionStatement + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression, // AssignmentExpression + (AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression as AssignmentExpression).right, // FunctionExpression + ]; + const functionNodeIndex = 2; + assert.deepStrictEqual('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${MethodDefinitionOfFooInClassBar.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + MethodDefinitionOfFooInClassBar.node, // ClassDeclaration + MethodDefinitionOfFooInClassBar.node.body, // ClassBody + MethodDefinitionOfFooInClassBar.node.body!.body[0], // MethodDefinition + (MethodDefinitionOfFooInClassBar.node.body!.body[0] as MethodDefinition).value, // FunctionExpression + ]; + const functionNodeIndex = 3; + assert.deepStrictEqual('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); +}); + +describe('wrapWithAwait', () => { + it('wraps a call expression with await', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node.expression); + wrapWithAwait(node); + + assert.deepStrictEqual('AwaitExpression', node.type); + assert.notDeepStrictEqual(SimpleCallExpressionOfFoo.node.expression.type, node.type); + assert.deepStrictEqual(SimpleCallExpressionOfFoo.node.expression, (node as AwaitExpression).argument); + }); + + it('throws if node is not an expression', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node); + assert.throws(() => wrapWithAwait(node as unknown as Expression)); + }); +}); + +describe('asyncifyScope', () => { + it('makes only the first function scope async', () => { + const node = structuredClone(SyncFunctionDeclarationWithAsyncCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body!.body[0], // ReturnStatement + (node.body!.body[0] as ReturnStatement).argument!, // ArrowFunctionExpression + ((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body, // AwaitExpression + (((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body as AwaitExpression).argument, // CallExpression + ]; + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + }; + + asyncifyScope(ancestors, state); + + // Assert the function did indeed change the expression to async + assert.deepStrictEqual(((node.body.body[0] as ReturnStatement).argument as ArrowFunctionExpression).async, true); + + // Assert the function did NOT change all ancestors in the chain + assert.deepStrictEqual(node.async, false); + + // Assert it couldn't find a function identifier + assert.deepStrictEqual(state.functionIdentifiers.size, 0); + }); +}); + +describe('checkReassignmentofModifiedIdentifiers', () => { + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBar.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBar.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarMemberExpression.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarMemberExpression.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarVariableDeclarator.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarVariableDeclarator.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.declarations[0], state, ancestors, ''); + + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarPropertyDefinition.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarPropertyDefinition.node); + const ancestors: AnyNode[] = [ + node, // ClassDeclaration + node.body, // ClassBody + node.body.body[0], // PropertyDefinition + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.body.body[0], state, ancestors, ''); + + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + }); +}); + +describe('buildFixModifiedFunctionsOperation', function () { + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(['foo']), + }; + + const fixFunction = buildFixModifiedFunctionsOperation(state.functionIdentifiers); + + beforeEach(() => { + state.isModified = false; + state.functionIdentifiers = new Set(['foo']); + }); + + it(`fixes calls of "foo" in the code "${FixSimpleCallExpression.code}"`, () => { + const node = structuredClone(FixSimpleCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body.body[0], // VariableDeclaration + (node.body.body[0] as VariableDeclaration).declarations[0], // VariableDeclarator + (node.body.body[0] as VariableDeclaration).declarations[0].init!, // CallExpression + ]; + + fixFunction(ancestors[4], state, ancestors, ''); + + assert.deepStrictEqual(state.isModified, true); + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + assert.notDeepStrictEqual(FixSimpleCallExpression.node, node); + assert.deepStrictEqual(node.async, true); + assert.deepStrictEqual(ancestors[4].type, 'AwaitExpression'); + }); + + it(`fixes calls of "foo" in the code "${ArrowFunctionDerefCallExpression.code}"`, () => { + const node = structuredClone(ArrowFunctionDerefCallExpression.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + node.declarations[0].init!, // ArrowFunctionExpression + (node.declarations[0].init as ArrowFunctionExpression).body, // CallExpression + ]; + + fixFunction(ancestors[3], state, ancestors, ''); + + // Recorded that a modification has been made + assert.deepStrictEqual(state.isModified, true); + // Recorded that the enclosing scope of the call also requires fixing + assert.deepStrictEqual(state.functionIdentifiers.has('bar'), true); + // Original node and fixed node are different + assert.notDeepStrictEqual(ArrowFunctionDerefCallExpression.node, node); + // The function call is now await'ed + assert.deepStrictEqual(ancestors[3].type, 'AwaitExpression'); + // The parent function of the call is now marked as async + assert.deepStrictEqual((ancestors[2] as ArrowFunctionExpression).async, true); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/codec.ts b/packages/apps/base-runtime/src/lib/codec.ts new file mode 100644 index 0000000000000..300ea2f60ff03 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/codec.ts @@ -0,0 +1,50 @@ +import { Buffer } from 'node:buffer'; + +import { decode, Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; +import { App } from '@rocket.chat/apps-engine/definition/App'; + +import { applySecureFields, type WithSecureFields } from './secureFields'; + +const FUNCTION_DISABLER_EXT = 0; +const BUFFER_HANDLER_EXT = 1; +const SECURE_FIELDS_HANDLER_EXT = 2; + +const extensionCodec = new ExtensionCodec(); + +extensionCodec.register({ + type: FUNCTION_DISABLER_EXT, + encode: (object: unknown) => { + // We don't care about functions, but also don't want to throw an error + if (typeof object === 'function' || object instanceof App) { + return new Uint8Array(0); + } + + return null; + }, + decode: (_data: Uint8Array) => undefined, +}); + +// Since Deno doesn't have Buffer by default, we need to use Uint8Array +extensionCodec.register({ + type: BUFFER_HANDLER_EXT, + encode: (object: unknown) => { + if (object instanceof Buffer) { + return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); + } + + return null; + }, + // msgpack will reuse the Uint8Array instance, so WE NEED to copy it instead of simply creating a view + decode: (data: Uint8Array) => { + return Buffer.from(data); + }, +}); + +extensionCodec.register({ + type: SECURE_FIELDS_HANDLER_EXT, + encode: (_object: unknown) => null, + decode: (data: Uint8Array) => applySecureFields(decode(data, { extensionCodec }) as WithSecureFields>), +}); + +export const encoder = new Encoder({ extensionCodec }); +export const decoder = new Decoder({ extensionCodec }); diff --git a/packages/apps/base-runtime/src/lib/logger.ts b/packages/apps/base-runtime/src/lib/logger.ts new file mode 100644 index 0000000000000..b7d6ba1c2a705 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/logger.ts @@ -0,0 +1,164 @@ +import type { ILogEntry } from '@rocket.chat/apps-engine/definition/accessors/ILogEntry'; +import type { ILogger } from '@rocket.chat/apps-engine/definition/accessors/ILogger'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata/AppMethod'; +import stackTrace from 'stack-trace'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; + +export interface IStackFrame { + getTypeName(): string; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number; + getColumnNumber(): number; + isNative(): boolean; + isConstructor(): boolean; +} + +enum LogMessageSeverity { + DEBUG = 'debug', + INFORMATION = 'info', + LOG = 'log', + WARNING = 'warning', + ERROR = 'error', + SUCCESS = 'success', +} + +type Entry = { + caller: string; + severity: LogMessageSeverity; + method: string; + timestamp: Date; + args: Array; +}; + +interface ILoggerStorageEntry { + appId: string; + method: string; + entries: Array; + startTime: Date; + endTime: Date; + totalTime: number; + _createdAt: Date; +} + +export class Logger implements ILogger { + public method: `${AppMethod}`; + + private entries: Array; + + private start: Date; + + constructor(method: string) { + this.method = method as `${AppMethod}`; + this.entries = []; + this.start = new Date(); + } + + public debug(...args: Array): void { + this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args); + } + + public info(...args: Array): void { + this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args); + } + + public log(...args: Array): void { + this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args); + } + + public warn(...args: Array): void { + this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args); + } + + public error(...args: Array): void { + this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args); + } + + public success(...args: Array): void { + this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args); + } + + private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { + const i = items.map((args) => { + if (args instanceof Error) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'stack' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'message' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + const str = JSON.stringify(args, null, 2); + return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references + }); + + this.entries.push({ + caller, + severity, + method: this.method, + timestamp: new Date(), + args: i, + }); + } + + private getStack(stack: Array): string { + let func = 'anonymous'; + + if (stack.length === 1) { + return func; + } + + const frame = stack[1]; + + if (frame.getMethodName() === null) { + func = 'anonymous OR constructor'; + } else { + func = frame.getMethodName(); + } + + if (frame.getFunctionName() !== null) { + func = `${func} -> ${frame.getFunctionName()}`; + } + + return func; + } + + public getTotalTime(): number { + return new Date().getTime() - this.start.getTime(); + } + + public getEntries(): Array { + return this.entries as Array; + } + + public getMethod(): `${AppMethod}` { + return this.method; + } + + public getStartTime(): Date { + return this.start; + } + + public getEndTime(): Date { + return new Date(); + } + + public hasEntries(): boolean { + return this.entries.length > 0; + } + + public getLogs(): ILoggerStorageEntry { + return { + appId: AppObjectRegistry.get('id')!, + method: this.method, + entries: this.entries, + startTime: this.start, + endTime: new Date(), + totalTime: this.getTotalTime(), + _createdAt: new Date(), + }; + } +} diff --git a/packages/apps/base-runtime/src/lib/messenger.ts b/packages/apps/base-runtime/src/lib/messenger.ts new file mode 100644 index 0000000000000..9cb2f19e3babb --- /dev/null +++ b/packages/apps/base-runtime/src/lib/messenger.ts @@ -0,0 +1,206 @@ +import EventEmitter from 'node:events'; + +import * as jsonrpc from 'jsonrpc-lite'; + +import { encoder } from './codec'; +import type { RequestContext } from './requestContext'; + +export type RequestDescriptor = Pick; + +export type NotificationDescriptor = Pick; + +export type SuccessResponseDescriptor = Pick; + +export type ErrorResponseDescriptor = Pick; + +export type JsonRpcRequest = jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification; +export type JsonRpcResponse = jsonrpc.IParsedObjectSuccess | jsonrpc.IParsedObjectError; + +export function isRequest(message: jsonrpc.IParsedObject): message is JsonRpcRequest { + return message.type === 'request' || message.type === 'notification'; +} + +export function isResponse(message: jsonrpc.IParsedObject): message is JsonRpcResponse { + return message.type === 'success' || message.type === 'error'; +} + +export function isErrorResponse(message: jsonrpc.JsonRpc): message is jsonrpc.ErrorObject { + return message instanceof jsonrpc.ErrorObject; +} + +const COMMAND_PONG = '_zPONG'; + +export const RPCResponseObserver = new EventEmitter(); + +class MessageQueue { + private queue: Uint8Array[] = []; + + private isProcessing = false; + + private async processQueue() { + if (this.isProcessing) { + return; + } + + this.isProcessing = true; + + while (this.queue.length) { + const message = this.queue.shift(); + + if (message) { + await transport.send(message); + } + } + + this.isProcessing = false; + } + + public enqueue(message: jsonrpc.JsonRpc | typeof COMMAND_PONG) { + this.queue.push(encoder.encode(message)); + void this.processQueue(); + } + + public getCurrentSize() { + return this.queue.length; + } +} + +export const Queue = new MessageQueue(); + +/** + * A platform-dependent component responsible for delivering encoded messages to + * the host that controls this runtime. + * + * Each runtime platform is expected to provide its own implementation and + * inject it via {@link setTransport}. + */ +export type Transport = { + send(message: Uint8Array): Promise; +}; + +/** + * The default transport. It discards every message, and is used until a + * platform injects its own transport via {@link setTransport}. + */ +export const noopTransport: Transport = { + send: () => Promise.resolve(), +}; + +let transport: Transport = noopTransport; + +/** + * Injects the transport implementation to be used when sending messages. + * + * Platforms must call this during bootstrap to wire up the appropriate + * transport. Until then, messages are discarded by the default no-op transport. + */ +export function setTransport(newTransport: Transport): void { + transport = newTransport; +} + +export function parseMessage(message: string | Record) { + let parsed: jsonrpc.IParsedObject | jsonrpc.IParsedObject[]; + + if (typeof message === 'string') { + parsed = jsonrpc.parse(message); + } else { + parsed = jsonrpc.parseObject(message); + } + + if (Array.isArray(parsed)) { + throw jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + } + + if (parsed.type === 'invalid') { + throw jsonrpc.error(null, parsed.payload); + } + + return parsed; +} + +export async function sendInvalidRequestError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + + await Queue.enqueue(rpc); +} + +export async function sendInvalidParamsError(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.invalidParams(null)); + + await Queue.enqueue(rpc); +} + +export async function sendParseError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.parseError(null)); + + await Queue.enqueue(rpc); +} + +export async function sendMethodNotFound(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.methodNotFound(null)); + + await Queue.enqueue(rpc); +} + +export async function errorResponse( + { error: { message, code = -32000, data = {} }, id }: ErrorResponseDescriptor, + req?: RequestContext, +): Promise { + const { logger } = req?.context || {}; + + if (logger?.hasEntries()) { + data.logs = logger.getLogs(); + } + + const rpc = jsonrpc.error(id, new jsonrpc.JsonRpcError(message, code, data)); + + await Queue.enqueue(rpc); +} + +export async function successResponse({ id, result }: SuccessResponseDescriptor, req: RequestContext): Promise { + const payload = { value: result } as Record; + const { logger } = req.context; + + if (logger.hasEntries()) { + payload.logs = logger.getLogs(); + } + + const rpc = jsonrpc.success(id, payload); + + await Queue.enqueue(rpc); +} + +export function pongResponse(): Promise { + return Promise.resolve(Queue.enqueue(COMMAND_PONG)); +} + +export async function sendRequest(requestDescriptor: RequestDescriptor): Promise { + const request = jsonrpc.request(Math.random().toString(36).slice(2), requestDescriptor.method, requestDescriptor.params); + + // TODO: add timeout to this + const responsePromise = new Promise((resolve, reject) => { + const handler = (payload: { error: Error } | { detail: jsonrpc.SuccessObject }) => { + if ('error' in payload) { + return reject(payload.error); + } + + return resolve(payload.detail); + }; + + RPCResponseObserver.once(`response:${request.id}`, handler); + }); + + await Queue.enqueue(request); + + return responsePromise as Promise; +} + +export function sendNotification({ method, params }: NotificationDescriptor) { + const request = jsonrpc.notification(method, params); + + Queue.enqueue(request); +} + +export function log(params: jsonrpc.RpcParams) { + sendNotification({ method: 'log', params }); +} diff --git a/packages/apps/base-runtime/src/lib/metricsCollector.ts b/packages/apps/base-runtime/src/lib/metricsCollector.ts new file mode 100644 index 0000000000000..bb0de35cb4f7d --- /dev/null +++ b/packages/apps/base-runtime/src/lib/metricsCollector.ts @@ -0,0 +1,21 @@ +import { Queue } from './messenger'; + +export function collectMetrics() { + return { + pid: process.pid, + queueSize: Queue.getCurrentSize(), + }; +} + +const encoder = new TextEncoder(); + +/** + * Sends metrics collected from the system via stderr + */ +export async function sendMetrics() { + const metrics = collectMetrics(); + + await new Promise((resolve, reject) => { + process.stderr.write(encoder.encode(JSON.stringify(metrics)), (err) => (err ? reject(err) : resolve())); + }); +} diff --git a/packages/apps/base-runtime/src/lib/parseArgs.ts b/packages/apps/base-runtime/src/lib/parseArgs.ts new file mode 100644 index 0000000000000..a10dbaf1778ae --- /dev/null +++ b/packages/apps/base-runtime/src/lib/parseArgs.ts @@ -0,0 +1,29 @@ +import { parseArgs as $parseArgs } from 'node:util'; + +export type ParsedArgs = { + subprocess: string; + spawnId: number; + metricsReportFrequencyInMs?: number; +}; + +export function parseArgs(args: string[]): ParsedArgs { + const { values } = $parseArgs({ + args, + options: { + subprocess: { type: 'string' }, + spawnId: { type: 'string' }, + metricsReportFrequencyInMs: { type: 'string' }, + }, + strict: false, + }); + + return { + subprocess: (values.subprocess as string) ?? '', + spawnId: Number(values.spawnId ?? 0), + metricsReportFrequencyInMs: + // isFinite is incorrectly typed in lib.es5.d.ts + typeof values.metricsReportFrequencyInMs !== 'undefined' && isFinite(values.metricsReportFrequencyInMs as any) + ? Number(values.metricsReportFrequencyInMs) + : undefined, + }; +} diff --git a/packages/apps/base-runtime/src/lib/requestContext.ts b/packages/apps/base-runtime/src/lib/requestContext.ts new file mode 100644 index 0000000000000..be5c5d6a98c07 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/requestContext.ts @@ -0,0 +1,10 @@ +import type { RequestObject } from 'jsonrpc-lite'; + +import type { Logger } from './logger'; + +export type RequestContext = RequestObject & { + context: { + logger: Logger; + [key: string]: unknown; + }; +}; diff --git a/packages/apps/base-runtime/src/lib/room.ts b/packages/apps/base-runtime/src/lib/room.ts new file mode 100644 index 0000000000000..540c7cba69202 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/room.ts @@ -0,0 +1,118 @@ +import type { IAbacAttributeDefinition } from '@rocket.chat/apps-engine/definition/abac/AbacAttributes'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +/** Minimal interface covering the only AppManager capability used by Room */ +interface IRoomManager { + getBridges(): { + getInternalBridge(): { + doGetUsernamesOfRoomById(id: string): Promise>; + }; + }; +} + +const PrivateManager = Symbol('RoomPrivateManager'); + +export class Room { + public id: string | undefined; + + public displayName?: string; + + public slugifiedName: string | undefined; + + public type: RoomType | undefined; + + public creator: IUser | undefined; + + public isDefault?: boolean; + + public isReadOnly?: boolean; + + public displaySystemMessages?: boolean; + + public messageCount?: number; + + public createdAt?: Date; + + public updatedAt?: Date; + + public lastModifiedAt?: Date; + + public customFields?: { [key: string]: unknown }; + + public userIds?: Array; + + public abacAttributes?: IAbacAttributeDefinition[]; + + private _USERNAMES: Promise> | undefined; + + private [PrivateManager]: IRoomManager | undefined; + + /** + * @deprecated + */ + public get usernames(): Promise> { + if (!this.id) return Promise.resolve([]); + + if (typeof this._USERNAMES === 'undefined') { + this._USERNAMES = this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES ?? Promise.resolve([]); + } + + public set usernames(usernames) {} + + public constructor(room: IRoom, manager: IRoomManager) { + Object.assign(this, room); + + Object.defineProperty(this, PrivateManager, { + configurable: false, + enumerable: false, + writable: false, + value: manager, + }); + } + + get value(): object { + return { + id: this.id, + displayName: this.displayName, + slugifiedName: this.slugifiedName, + type: this.type, + creator: this.creator, + isDefault: this.isDefault, + isReadOnly: this.isReadOnly, + displaySystemMessages: this.displaySystemMessages, + messageCount: this.messageCount, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + lastModifiedAt: this.lastModifiedAt, + customFields: this.customFields, + userIds: this.userIds, + abacAttributes: this.abacAttributes, + }; + } + + public async getUsernames(): Promise> { + // Get usernames + if (typeof this._USERNAMES === 'undefined') { + this._USERNAMES = this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id!); + } + + return (await this._USERNAMES) || []; + } + + public toJSON() { + return this.value; + } + + public toString() { + return this.value; + } + + public valueOf() { + return this.value; + } +} diff --git a/packages/apps/base-runtime/src/lib/roomFactory.ts b/packages/apps/base-runtime/src/lib/roomFactory.ts new file mode 100644 index 0000000000000..803d55c2a537f --- /dev/null +++ b/packages/apps/base-runtime/src/lib/roomFactory.ts @@ -0,0 +1,29 @@ +import type { AppManager } from '@rocket.chat/apps/dist/server/AppManager'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; + +import { formatErrorResponse } from './accessors/formatResponseErrorHandler'; +import type { AppAccessors } from './accessors/mod'; +import { Room } from './room'; + +const getMockAppManager = (senderFn: AppAccessors['senderFn']) => ({ + getBridges: () => ({ + getInternalBridge: () => ({ + doGetUsernamesOfRoomById: (roomId: string) => { + return senderFn({ + method: 'bridges:getInternalBridge:doGetUsernamesOfRoomById', + params: [roomId], + }) + .then((result) => result.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }), + }), +}); + +export default function createRoom(room: IRoom, senderFn: AppAccessors['senderFn']) { + const mockAppManager = getMockAppManager(senderFn); + + return new Room(room, mockAppManager as unknown as AppManager); +} diff --git a/packages/apps/base-runtime/src/lib/sanitizeDeprecatedUsage.ts b/packages/apps/base-runtime/src/lib/sanitizeDeprecatedUsage.ts new file mode 100644 index 0000000000000..aa2898418db81 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/sanitizeDeprecatedUsage.ts @@ -0,0 +1,20 @@ +import { fixBrokenSynchronousAPICalls } from './ast/mod'; + +function hasPotentialDeprecatedUsage(source: string) { + return ( + // potential usage of Room.usernames getter + source.includes('.usernames') || + // potential usage of LivechatRead.isOnline method + source.includes('.isOnline(') || + // potential usage of LivechatCreator.createToken method + source.includes('.createToken(') + ); +} + +export function sanitizeDeprecatedUsage(source: string) { + if (!hasPotentialDeprecatedUsage(source)) { + return source; + } + + return fixBrokenSynchronousAPICalls(source); +} diff --git a/packages/apps/base-runtime/src/lib/secureFields.ts b/packages/apps/base-runtime/src/lib/secureFields.ts new file mode 100644 index 0000000000000..9d31ad745f5b3 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/secureFields.ts @@ -0,0 +1,27 @@ +import type { WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; +import { kSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../AppObjectRegistry'; + +export type { WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; + +export function applySecureFields(object: WithSecureFields>) { + const { [kSecureFields]: secureFields, ...rest } = object; + + const app = AppObjectRegistry.get('app'); + + if (!app) { + throw new Error("App unavailable, can't parse object with secure fields"); + } + + secureFields.forEach(({ permission, name, value }) => { + if (!app.getInfo().permissions?.find((p) => p.name === permission)) { + return; + } + + rest[name] = value; + }); + + return rest; +} diff --git a/packages/apps/base-runtime/src/lib/tests/logger.test.ts b/packages/apps/base-runtime/src/lib/tests/logger.test.ts new file mode 100644 index 0000000000000..e3110a70390b2 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/tests/logger.test.ts @@ -0,0 +1,111 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { Logger } from '../logger'; + +describe('Logger', () => { + it('getLogs should return an array of entries', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.method, 'test'); + }); + + it('should be able to add entries of different severity', () => { + const logger = new Logger('test'); + logger.info('test'); + logger.debug('test'); + logger.error('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 3); + assert.deepStrictEqual(logs.entries[0].severity, 'info'); + assert.deepStrictEqual(logs.entries[1].severity, 'debug'); + assert.deepStrictEqual(logs.entries[2].severity, 'error'); + }); + + it('should be able to add an info entry', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'info'); + }); + + it('should be able to add an debug entry', () => { + const logger = new Logger('test'); + logger.debug('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'debug'); + }); + + it('should be able to add an error entry', () => { + const logger = new Logger('test'); + logger.error('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'error'); + }); + + it('should be able to add an success entry', () => { + const logger = new Logger('test'); + logger.success('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'success'); + }); + + it('should be able to add an warning entry', () => { + const logger = new Logger('test'); + logger.warn('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'warning'); + }); + + it('should be able to add an log entry', () => { + const logger = new Logger('test'); + logger.log('test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments', () => { + const logger = new Logger('test'); + logger.log('test', 'test', 'test'); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].args[1], 'test'); + assert.deepStrictEqual(logs.entries[0].args[2], 'test'); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments of different types', () => { + const logger = new Logger('test'); + logger.log('test', 1, true, { foo: 'bar' }); + const logs = logger.getLogs(); + assert.deepStrictEqual(logs.entries.length, 1); + assert.deepStrictEqual(logs.entries[0].args[0], 'test'); + assert.deepStrictEqual(logs.entries[0].args[1], 1); + assert.deepStrictEqual(logs.entries[0].args[2], true); + assert.deepStrictEqual(logs.entries[0].args[3], { foo: 'bar' }); + assert.deepStrictEqual(logs.entries[0].method, 'test'); + assert.deepStrictEqual(logs.entries[0].severity, 'log'); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/tests/messenger.test.ts b/packages/apps/base-runtime/src/lib/tests/messenger.test.ts new file mode 100644 index 0000000000000..936123cfae14a --- /dev/null +++ b/packages/apps/base-runtime/src/lib/tests/messenger.test.ts @@ -0,0 +1,79 @@ +import * as assert from 'node:assert'; +import { after, beforeEach, describe, it, mock } from 'node:test'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { createMockRequest } from '../../handlers/tests/helpers/mod'; +import * as Messenger from '../messenger'; +import type { RequestContext } from '../requestContext'; + +describe('Messenger', () => { + let context: RequestContext; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'test'); + Messenger.setTransport(Messenger.noopTransport); + + context = createMockRequest({ method: 'test', params: [] }); + }); + + after(() => { + AppObjectRegistry.clear(); + Messenger.setTransport(Messenger.noopTransport); + }); + + it('should add logs to success responses', async () => { + const theSpy = mock.method(Messenger.Queue, 'enqueue'); + const { logger } = context.context; + + logger.info('test'); + + await Messenger.successResponse({ id: 'test', result: 'test' }, context); + + assert.strictEqual(theSpy.mock.calls.length, 1); + + const [responseArgument] = theSpy.mock.calls[0].arguments; + const resp = responseArgument as any; + + assert.strictEqual(resp.jsonrpc, '2.0'); + assert.strictEqual(resp.id, 'test'); + assert.strictEqual(resp.result.value, 'test'); + assert.strictEqual(resp.result.logs.appId, 'test'); + assert.strictEqual(resp.result.logs.method, 'test'); + assert.strictEqual(resp.result.logs.entries.length, 1); + assert.strictEqual(resp.result.logs.entries[0].severity, 'info'); + assert.strictEqual(resp.result.logs.entries[0].method, 'test'); + assert.deepStrictEqual(resp.result.logs.entries[0].args, ['test']); + assert.strictEqual(resp.result.logs.entries[0].caller, 'anonymous OR constructor'); + + theSpy.mock.restore(); + }); + + it('should add logs to error responses', async () => { + const theSpy = mock.method(Messenger.Queue, 'enqueue'); + const { logger } = context.context; + + logger.info('test'); + + await Messenger.errorResponse({ id: 'test', error: { code: -32000, message: 'test' } }, context); + + assert.strictEqual(theSpy.mock.calls.length, 1); + + const [responseArgument] = theSpy.mock.calls[0].arguments; + const resp = responseArgument as any; + + assert.strictEqual(resp.jsonrpc, '2.0'); + assert.strictEqual(resp.id, 'test'); + assert.strictEqual(resp.error.code, -32000); + assert.strictEqual(resp.error.message, 'test'); + assert.strictEqual(resp.error.data.logs.appId, 'test'); + assert.strictEqual(resp.error.data.logs.method, 'test'); + assert.strictEqual(resp.error.data.logs.entries.length, 1); + assert.strictEqual(resp.error.data.logs.entries[0].severity, 'info'); + assert.strictEqual(resp.error.data.logs.entries[0].method, 'test'); + assert.deepStrictEqual(resp.error.data.logs.entries[0].args, ['test']); + assert.strictEqual(resp.error.data.logs.entries[0].caller, 'anonymous OR constructor'); + + theSpy.mock.restore(); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/tests/secureFields.test.ts b/packages/apps/base-runtime/src/lib/tests/secureFields.test.ts new file mode 100644 index 0000000000000..3b0643c3d7868 --- /dev/null +++ b/packages/apps/base-runtime/src/lib/tests/secureFields.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'node:assert'; +import { beforeEach, describe, it } from 'node:test'; + +import { AppObjectRegistry } from '../../AppObjectRegistry'; +import { applySecureFields } from '../secureFields'; + +const SECURE_FIELDS_KEY = '@@SecureFields'; + +describe('applySecureFields', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + }); + + it('throws when app is unavailable', () => { + assert.throws(() => applySecureFields({ foo: 'bar', [SECURE_FIELDS_KEY]: [] } as any), { + message: "App unavailable, can't parse object with secure fields", + }); + }); + + it('applies only secure fields with matching permissions', () => { + AppObjectRegistry.set('app', { + getInfo: () => ({ + permissions: [{ name: 'abac.read' }], + }), + }); + + const parsed = applySecureFields({ + foo: 'bar', + [SECURE_FIELDS_KEY]: [ + { permission: 'abac.read', name: 'abacAttributes', value: { department: 'support' } }, + { permission: 'api.read', name: 'apiToken', value: 'secret' }, + ], + } as any); + + assert.deepStrictEqual(parsed, { + foo: 'bar', + abacAttributes: { department: 'support' }, + }); + }); + + it('overwrites an existing field when permission is granted', () => { + AppObjectRegistry.set('app', { + getInfo: () => ({ + permissions: [{ name: 'abac.read' }], + }), + }); + + const parsed = applySecureFields({ + abacAttributes: null, + [SECURE_FIELDS_KEY]: [{ permission: 'abac.read', name: 'abacAttributes', value: { tenant: 'alpha' } }], + } as any); + + assert.deepStrictEqual(parsed, { + abacAttributes: { tenant: 'alpha' }, + }); + }); +}); diff --git a/packages/apps/base-runtime/src/lib/wrapAppForRequest.ts b/packages/apps/base-runtime/src/lib/wrapAppForRequest.ts new file mode 100644 index 0000000000000..a30274c20eaed --- /dev/null +++ b/packages/apps/base-runtime/src/lib/wrapAppForRequest.ts @@ -0,0 +1,60 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import type { RequestContext } from './requestContext'; +import { isApp, isRecord } from '../handlers/lib/assertions'; + +export function wrapAppForRequest(app: App, req: RequestContext): App { + return new Proxy(app, { + get(target, property, receiver) { + if (property === 'logger') { + return req.context.logger; + } + + return Reflect.get(target, property, receiver); + }, + }); +} + +// Instances of objects that have a reference to an App instance won't change throughout the +// lifetime of the runtime, so we can cache the results to avoid iterating the same object multiple times +const composedCache = new WeakMap, ReturnType>(); + +function findAppProperty(v: NonNullable): [string, App] | undefined { + const cachedEntry = composedCache.get(v); + + if (cachedEntry) { + return cachedEntry; + } + + if (!isRecord(v)) { + // Enables us to avoid having to determine whether the value is a record again + composedCache.set(v, undefined); + return undefined; + } + + const entry = Object.entries(v).find(([_, v]) => isApp(v)) as [string, App] | undefined; + + composedCache.set(v, entry); + + return entry; +} + +export function wrapComposedApp>(composed: T, req: RequestContext): T { + const prop = findAppProperty(composed); + + if (!prop) { + return composed; + } + + const proxy = wrapAppForRequest(prop[1], req); + + return new Proxy(composed, { + get(target, property, receiver) { + if (property === prop[0]) { + return proxy; + } + + return Reflect.get(target, property, receiver); + }, + }); +} diff --git a/packages/apps/base-runtime/src/mainLoop.ts b/packages/apps/base-runtime/src/mainLoop.ts new file mode 100644 index 0000000000000..d8b125e959ebf --- /dev/null +++ b/packages/apps/base-runtime/src/mainLoop.ts @@ -0,0 +1,127 @@ +import process from 'node:process'; + +import { JsonRpcError, type SuccessObject } from 'jsonrpc-lite'; + +import apiHandler from './handlers/api-handler'; +import handleApp from './handlers/app/handler'; +import outboundMessageHandler from './handlers/outboundcomms-handler'; +import handleScheduler from './handlers/scheduler-handler'; +import slashcommandHandler from './handlers/slashcommand-handler'; +import videoConferenceHandler from './handlers/videoconference-handler'; +import { decoder } from './lib/codec'; +import { Logger } from './lib/logger'; +import * as Messenger from './lib/messenger'; +import { sendMetrics } from './lib/metricsCollector'; +import type { RequestContext } from './lib/requestContext'; + +type Handlers = { + app: typeof handleApp; + api: typeof apiHandler; + slashcommand: typeof slashcommandHandler; + videoconference: typeof videoConferenceHandler; + outboundCommunication: typeof outboundMessageHandler; + scheduler: typeof handleScheduler; + ping: (request: RequestContext) => Promise<'pong'>; +}; + +const COMMAND_PING = '_zPING'; + +async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promise { + const methodHandlers: Handlers = { + app: handleApp, + api: apiHandler, + slashcommand: slashcommandHandler, + videoconference: videoConferenceHandler, + outboundCommunication: outboundMessageHandler, + scheduler: handleScheduler, + ping: (_request) => Promise.resolve('pong'), + }; + + // We're not handling notifications at the moment + if (type === 'notification') { + return Messenger.sendInvalidRequestError(); + } + + const { id, method } = payload; + + const logger = new Logger(method); + + const context: RequestContext = Object.assign(payload, { + context: { logger }, + }); + + const [methodPrefix] = method.split(':') as [keyof Handlers]; + const handler = methodHandlers[methodPrefix]; + + if (!handler) { + return Messenger.errorResponse( + { + error: { message: 'Method not found', code: -32601 }, + id, + }, + context, + ); + } + + const result = await handler(context).catch((reason) => + JsonRpcError.internalError({ cause: reason instanceof Error ? reason.toString() : reason }), + ); + + if (result instanceof JsonRpcError) { + return Messenger.errorResponse({ id, error: result }, context); + } + + return Messenger.successResponse({ id, result }, context); +} + +function handleResponse(response: Messenger.JsonRpcResponse): void { + let payload: { error: Error } | { detail: SuccessObject }; + + if (Messenger.isErrorResponse(response.payload)) { + payload = { error: new Error(response.payload.error.message) }; + } else { + payload = { detail: response.payload }; + } + + Messenger.RPCResponseObserver.emit(`response:${response.payload.id}`, payload); +} + +/** + * The platform-agnostic message loop shared by every runtime. + * + * Adapters are expected to wire up their platform seams — transport, sandbox + * `require`/globals, error listeners — during bootstrap and only then invoke + * this loop. It reads messages from `process.stdin` (a `node:` API available on + * every supported platform) and dispatches them to the shared handlers. + */ +export async function startMainLoop(): Promise { + Messenger.sendNotification({ method: 'ready', params: [] }); + + for await (const message of decoder.decodeStream(process.stdin)) { + try { + // Process PING command first as it is not JSON RPC + if (message === COMMAND_PING) { + void Messenger.pongResponse(); + void sendMetrics(); + continue; + } + + const JSONRPCMessage = Messenger.parseMessage(message as Record); + + if (Messenger.isRequest(JSONRPCMessage)) { + void requestRouter(JSONRPCMessage); + continue; + } + + if (Messenger.isResponse(JSONRPCMessage)) { + handleResponse(JSONRPCMessage); + } + } catch (error) { + if (Messenger.isErrorResponse(error)) { + await Messenger.errorResponse(error); + } else { + await Messenger.sendParseError(); + } + } + } +} diff --git a/packages/apps/base-runtime/tsconfig.json b/packages/apps/base-runtime/tsconfig.json new file mode 100644 index 0000000000000..3b75a52883b11 --- /dev/null +++ b/packages/apps/base-runtime/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": ".", + "types": ["node"], + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2023", + "declaration": true, + "paths": { + "@rocket.chat/apps/*": ["../*"] + } + }, + "include": ["./src/**/*"] +} diff --git a/packages/apps/package.json b/packages/apps/package.json index bd52d544df0f9..b73fcfc0dd6f5 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -26,7 +26,10 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/model-typings": "workspace:^", + "acorn": "8.17.0", + "acorn-walk": "8.3.5", "adm-zip": "^0.5.16", + "astring": "1.8.6", "debug": "^4.3.7", "esbuild": "~0.28.1", "jose": "^4.15.9", diff --git a/yarn.lock b/yarn.lock index ec1d563c3e908..90f46c4cf8bcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8809,7 +8809,10 @@ __metadata: "@types/semver": "npm:^7.5.8" "@types/stack-trace": "npm:0.0.33" "@types/uuid": "npm:~10.0.0" + acorn: "npm:8.17.0" + acorn-walk: "npm:8.3.5" adm-zip: "npm:^0.5.16" + astring: "npm:1.8.6" debug: "npm:^4.3.7" esbuild: "npm:~0.28.1" eslint: "npm:~9.39.4" @@ -15677,6 +15680,15 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:8.3.5": + version: 8.3.5 + resolution: "acorn-walk@npm:8.3.5" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10/f52a158a1c1f00c82702c7eb9b8ae8aad79748a7689241dcc2d797dce680f1dcb15c78f312f687eeacdfb3a4cac4b87d04af470f0201bd56c6661fca6f94b195 + languageName: node + linkType: hard + "acorn-walk@npm:^8.1.1": version: 8.3.2 resolution: "acorn-walk@npm:8.3.2" @@ -15684,6 +15696,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:8.17.0, acorn@npm:^8.11.0": + version: 8.17.0 + resolution: "acorn@npm:8.17.0" + bin: + acorn: bin/acorn + checksum: 10/2eea1588075124df569b15995423204055c5575ad992283025dddfcb557a0340de7d75cc1bc25dca8df148c60c4222e576e0e519965f0ec7f86f6085c8428824 + languageName: node + linkType: hard + "acorn@npm:^8.12.1, acorn@npm:^8.14.0, acorn@npm:^8.4.1": version: 8.14.0 resolution: "acorn@npm:8.14.0" @@ -16381,6 +16402,15 @@ __metadata: languageName: node linkType: hard +"astring@npm:1.8.6": + version: 1.8.6 + resolution: "astring@npm:1.8.6" + bin: + astring: bin/astring + checksum: 10/5c1eb7cf3e8ff7da2021c887dddd887c6ae307767e76ee4418eb02dfee69794c397ea4dccaf3f28975ecd8eb32a5fe4dce108d35b2e4c6429c2a7ec9b7b7de57 + languageName: node + linkType: hard + "async-mutex@npm:^0.5.0, async-mutex@npm:~0.5.0": version: 0.5.0 resolution: "async-mutex@npm:0.5.0" From 6bf2b911b5ea8dafc5d953d3583a931a1d498cb6 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 30 Jun 2026 16:28:57 -0300 Subject: [PATCH 2/4] build(apps): wire base-runtime into build, typecheck and test pipelines Add build:base-runtime and typecheck:base-runtime (tsc over base-runtime/tsconfig.json), sequence the base build before the node and deno-cache steps, publish base-runtime/ in the package files, and point the relocated base test suite at base-runtime/** (renamed from test:node-runtime, since those base-logic suites now live in base-runtime and run under node:test). Co-Authored-By: Claude Opus 4.8 (1M context) test(apps): run apps test files serially to avoid subprocess races The Deno-spawning integration tests (DenoRuntimeSubprocessController, SecureFieldsCodecCompatibility) share a fixed os.tmpdir()/deno-runtime symlink, so under the default concurrent node --test one file's teardown unlinks the entrypoint another file is mid-spawn on (Module not found .../deno-runtime/main.ts). Pin --test-concurrency=1 so files run one at a time. Co-Authored-By: Claude Opus 4.8 (1M context) base-runtime: include in turbo output --- packages/apps/package.json | 13 +++++++++---- packages/apps/turbo.json | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/apps/package.json b/packages/apps/package.json index b73fcfc0dd6f5..1d02722b51bec 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -6,20 +6,25 @@ "types": "./dist/index.d.ts", "files": [ "dist/", + "base-runtime/", "deno-runtime/", ".deno-cache/" ], "scripts": { - "build": "run-s build:clean build:default build:deno-cache", - "build:clean": "rimraf dist", + "build": "run-s build:clean build:default build:base-runtime build:deno-cache", + "build:clean": "rimraf dist base-runtime/dist", "build:default": "tsc -p tsconfig.json", + "build:base-runtime": "tsc -p base-runtime/tsconfig.json", "build:deno-cache": "node scripts/deno-cache.js", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "lint": "eslint .", "test:deno": "deno task --config=deno-runtime/deno.jsonc test", "test:node": "NODE_ENV=test node --require ts-node/register/transpile-only --test-reporter spec --test-concurrency=1 --test \"tests/**/*.test.ts\"", - "testunit": "yarn test:node && yarn test:deno", - "typecheck": "tsc -p tsconfig.json --noEmit" + "test:base-runtime": "NODE_ENV=test node --require ts-node/register/transpile-only --test-reporter spec --test-concurrency=1 --test \"base-runtime/**/*.test.ts\"", + "testunit": "run-s test:node test:deno test:base-runtime", + "typecheck:default": "tsc -p tsconfig.json --noEmit", + "typecheck:base-runtime": "tsc -p base-runtime/tsconfig.json --noEmit", + "typecheck": "run-s typecheck:default typecheck:base-runtime" }, "dependencies": { "@msgpack/msgpack": "3.0.0-beta2", diff --git a/packages/apps/turbo.json b/packages/apps/turbo.json index 0c2eb270418c2..ad6d1f980e819 100644 --- a/packages/apps/turbo.json +++ b/packages/apps/turbo.json @@ -4,7 +4,7 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.tool-versions"], - "outputs": ["deno-runtime/**", "scripts/**", ".deno-cache/**", "dist/**"] + "outputs": ["base-runtime/**", "deno-runtime/**", "scripts/**", ".deno-cache/**", "dist/**"] } } } From cec408ff772700b2fc8bd683ac8bfcbdce6496d1 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 2 Jul 2026 15:42:12 -0300 Subject: [PATCH 3/4] refactor(deno-runtime): reduce deno-runtime to a wrapper for base-runtime refactor(deno-runtime): replace imports for base-runtime refactor(deno-runtime): implement full require logic fix(deno-runtime): module resolution --- .../apps/deno-runtime/AppObjectRegistry.ts | 25 - packages/apps/deno-runtime/deno.jsonc | 1 + packages/apps/deno-runtime/error-handlers.ts | 2 +- .../apps/deno-runtime/handlers/api-handler.ts | 50 -- .../deno-runtime/handlers/app/construct.ts | 158 ------ .../handlers/app/handleGetStatus.ts | 15 - .../handlers/app/handleInitialize.ts | 24 - .../handlers/app/handleOnDisable.ts | 20 - .../handlers/app/handleOnEnable.ts | 22 - .../handlers/app/handleOnInstall.ts | 34 -- .../handlers/app/handleOnPreSettingUpdate.ts | 31 -- .../handlers/app/handleOnSettingUpdated.ts | 33 -- .../handlers/app/handleOnUninstall.ts | 34 -- .../handlers/app/handleOnUpdate.ts | 34 -- .../handlers/app/handleSetStatus.ts | 28 - .../handlers/app/handleUploadEvents.ts | 81 --- .../apps/deno-runtime/handlers/app/handler.ts | 115 ---- .../deno-runtime/handlers/lib/assertions.ts | 51 -- .../deno-runtime/handlers/listener/handler.ts | 148 ----- .../handlers/outboundcomms-handler.ts | 37 -- .../handlers/scheduler-handler.ts | 65 --- .../handlers/slashcommand-handler.ts | 121 ---- .../handlers/tests/api-handler.test.ts | 118 ---- .../handlers/tests/helpers/mod.ts | 29 - .../handlers/tests/listener-handler.test.ts | 234 -------- .../handlers/tests/scheduler-handler.test.ts | 41 -- .../tests/slashcommand-handler.test.ts | 159 ------ .../handlers/tests/uikit-handler.test.ts | 105 ---- .../tests/upload-event-handler.test.ts | 112 ---- .../tests/videoconference-handler.test.ts | 122 ----- .../deno-runtime/handlers/uikit/handler.ts | 100 ---- .../handlers/videoconference-handler.ts | 52 -- .../lib/accessors/builders/BlockBuilder.ts | 16 - .../accessors/builders/DiscussionBuilder.ts | 49 -- .../builders/LivechatMessageBuilder.ts | 199 ------- .../lib/accessors/builders/MessageBuilder.ts | 266 --------- .../lib/accessors/builders/RoomBuilder.ts | 191 ------- .../lib/accessors/builders/UserBuilder.ts | 75 --- .../builders/VideoConferenceBuilder.ts | 88 --- .../lib/accessors/extenders/HttpExtender.ts | 58 -- .../accessors/extenders/MessageExtender.ts | 60 -- .../lib/accessors/extenders/RoomExtender.ts | 55 -- .../extenders/VideoConferenceExtend.ts | 64 --- .../accessors/formatResponseErrorHandler.ts | 14 - .../apps/deno-runtime/lib/accessors/http.ts | 92 ---- .../apps/deno-runtime/lib/accessors/mod.ts | 354 ------------ .../lib/accessors/modify/ModifyCreator.ts | 381 ------------- .../lib/accessors/modify/ModifyExtender.ts | 101 ---- .../lib/accessors/modify/ModifyUpdater.ts | 163 ------ .../deno-runtime/lib/accessors/notifier.ts | 79 --- .../lib/accessors/tests/AppAccessors.test.ts | 122 ----- .../lib/accessors/tests/ModifyCreator.test.ts | 259 --------- .../accessors/tests/ModifyExtender.test.ts | 244 --------- .../lib/accessors/tests/ModifyUpdater.test.ts | 243 --------- .../tests/formatResponseErrorHandler.test.ts | 211 ------- .../lib/accessors/tests/http.test.ts | 164 ------ packages/apps/deno-runtime/lib/ast/mod.ts | 69 --- .../apps/deno-runtime/lib/ast/operations.ts | 235 -------- .../lib/ast/tests/data/ast_blocks.ts | 515 ------------------ .../lib/ast/tests/operations.test.ts | 261 --------- packages/apps/deno-runtime/lib/codec.ts | 50 -- packages/apps/deno-runtime/lib/logger.ts | 164 ------ packages/apps/deno-runtime/lib/messenger.ts | 200 ------- .../apps/deno-runtime/lib/metricsCollector.ts | 23 - packages/apps/deno-runtime/lib/parseArgs.ts | 28 - .../apps/deno-runtime/lib/requestContext.ts | 10 - packages/apps/deno-runtime/lib/require.ts | 44 +- packages/apps/deno-runtime/lib/room.ts | 118 ---- packages/apps/deno-runtime/lib/roomFactory.ts | 29 - .../lib/sanitizeDeprecatedUsage.ts | 20 - .../apps/deno-runtime/lib/secureFields.ts | 26 - .../deno-runtime/lib/tests/logger.test.ts | 110 ---- .../deno-runtime/lib/tests/messenger.test.ts | 100 ---- .../lib/tests/secureFields.test.ts | 59 -- .../lib/transports/stdoutTransport.ts | 2 +- .../deno-runtime/lib/wrapAppForRequest.ts | 60 -- packages/apps/deno-runtime/main.ts | 12 +- packages/apps/deno-runtime/mainLoop.ts | 127 ----- .../deno-runtime/tests/error-handlers.test.ts | 2 +- 79 files changed, 47 insertions(+), 7966 deletions(-) delete mode 100644 packages/apps/deno-runtime/AppObjectRegistry.ts delete mode 100644 packages/apps/deno-runtime/handlers/api-handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/construct.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleGetStatus.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleInitialize.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnDisable.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnEnable.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnInstall.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleSetStatus.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts delete mode 100644 packages/apps/deno-runtime/handlers/app/handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/lib/assertions.ts delete mode 100644 packages/apps/deno-runtime/handlers/listener/handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/outboundcomms-handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/scheduler-handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/slashcommand-handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/api-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/helpers/mod.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts delete mode 100644 packages/apps/deno-runtime/handlers/uikit/handler.ts delete mode 100644 packages/apps/deno-runtime/handlers/videoconference-handler.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/http.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/mod.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/notifier.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts delete mode 100644 packages/apps/deno-runtime/lib/accessors/tests/http.test.ts delete mode 100644 packages/apps/deno-runtime/lib/ast/mod.ts delete mode 100644 packages/apps/deno-runtime/lib/ast/operations.ts delete mode 100644 packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts delete mode 100644 packages/apps/deno-runtime/lib/ast/tests/operations.test.ts delete mode 100644 packages/apps/deno-runtime/lib/codec.ts delete mode 100644 packages/apps/deno-runtime/lib/logger.ts delete mode 100644 packages/apps/deno-runtime/lib/messenger.ts delete mode 100644 packages/apps/deno-runtime/lib/metricsCollector.ts delete mode 100644 packages/apps/deno-runtime/lib/parseArgs.ts delete mode 100644 packages/apps/deno-runtime/lib/requestContext.ts delete mode 100644 packages/apps/deno-runtime/lib/room.ts delete mode 100644 packages/apps/deno-runtime/lib/roomFactory.ts delete mode 100644 packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts delete mode 100644 packages/apps/deno-runtime/lib/secureFields.ts delete mode 100644 packages/apps/deno-runtime/lib/tests/logger.test.ts delete mode 100644 packages/apps/deno-runtime/lib/tests/messenger.test.ts delete mode 100644 packages/apps/deno-runtime/lib/tests/secureFields.test.ts delete mode 100644 packages/apps/deno-runtime/lib/wrapAppForRequest.ts delete mode 100644 packages/apps/deno-runtime/mainLoop.ts diff --git a/packages/apps/deno-runtime/AppObjectRegistry.ts b/packages/apps/deno-runtime/AppObjectRegistry.ts deleted file mode 100644 index c9c05137a4a3d..0000000000000 --- a/packages/apps/deno-runtime/AppObjectRegistry.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type Maybe = T | null | undefined; - -export const AppObjectRegistry = new class { - registry: Record = {}; - - public get(key: string): Maybe { - return this.registry[key] as Maybe; - } - - public set(key: string, value: unknown): void { - this.registry[key] = value; - } - - public has(key: string): boolean { - return key in this.registry; - } - - public delete(key: string): void { - delete this.registry[key]; - } - - public clear(): void { - this.registry = {}; - } -}(); diff --git a/packages/apps/deno-runtime/deno.jsonc b/packages/apps/deno-runtime/deno.jsonc index 09a5d66f54459..ac643c199061b 100644 --- a/packages/apps/deno-runtime/deno.jsonc +++ b/packages/apps/deno-runtime/deno.jsonc @@ -3,6 +3,7 @@ "@msgpack/msgpack": "npm:@msgpack/msgpack@3.0.0-beta2", "@rocket.chat/ui-kit": "npm:@rocket.chat/ui-kit@^0.31.22", "@rocket.chat/apps-engine/": "../../../packages/apps-engine/", + "@rocket.chat/apps/base-runtime/": "../base-runtime/src/", "@rocket.chat/apps/": "../", "@std/io": "jsr:@std/io@^0.225.3", "acorn": "npm:acorn@8.17.0", diff --git a/packages/apps/deno-runtime/error-handlers.ts b/packages/apps/deno-runtime/error-handlers.ts index 25df23a58df90..62d52c244860e 100644 --- a/packages/apps/deno-runtime/error-handlers.ts +++ b/packages/apps/deno-runtime/error-handlers.ts @@ -1,4 +1,4 @@ -import * as Messenger from './lib/messenger'; +import * as Messenger from '@rocket.chat/apps/base-runtime/lib/messenger'; export function unhandledRejectionListener(event: PromiseRejectionEvent) { event.preventDefault(); diff --git a/packages/apps/deno-runtime/handlers/api-handler.ts b/packages/apps/deno-runtime/handlers/api-handler.ts deleted file mode 100644 index b59b471981942..0000000000000 --- a/packages/apps/deno-runtime/handlers/api-handler.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; -import { AppAccessorsInstance } from '../lib/accessors/mod'; -import { RequestContext } from '../lib/requestContext'; -import { wrapComposedApp } from '../lib/wrapAppForRequest'; - -export default async function apiHandler(request: RequestContext): Promise { - const { method: call, params } = request; - const [/* always "api" */, ...parts] = call.split(':'); - const httpMethod = parts.pop(); - const path = parts.join(':'); - - const endpoint = AppObjectRegistry.get(`api:${path}`); - const { logger } = request.context; - - if (!endpoint) { - return new JsonRpcError(`Endpoint ${path} not found`, -32000); - } - - const method = endpoint[httpMethod as keyof IApiEndpoint]; - - if (typeof method !== 'function') { - return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000); - } - - const [requestData, endpointInfo] = params as Array; - - logger.debug(`${path}'s ${call} is being executed...`, requestData); - - try { - // deno-lint-ignore ban-types - const result = await (method as Function).apply(wrapComposedApp(endpoint, request), [ - requestData, - endpointInfo, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getModifier(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - ]); - - logger.debug(`${path}'s ${call} was successfully executed.`); - - return result; - } catch (e) { - logger.debug(`${path}'s ${call} was unsuccessful.`); - return new JsonRpcError(e.message || 'Internal server error', -32000); - } -} diff --git a/packages/apps/deno-runtime/handlers/app/construct.ts b/packages/apps/deno-runtime/handlers/app/construct.ts deleted file mode 100644 index a359de051aaa8..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/construct.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { IParseAppPackageResult } from '@rocket.chat/apps/dist/server/compiler/IParseAppPackageResult'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; - -const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring', 'fs']; -const ALLOWED_EXTERNAL_MODULES = ['uuid']; - -/** - * A platform-dependent `require` used to resolve the modules an app is allowed - * to load (native `node:` modules, a small allow-list of npm packages, and - * apps-engine files). Each runtime injects its own via {@link setSandboxRequire} - * — Node hands over its global `require`, Deno hands over its `createRequire` - * shim that knows how to resolve compiled apps-engine paths. - */ -type SandboxRequire = (module: string) => unknown; - -function defaultSandboxRequire(): never { - throw new Error('No sandbox require has been injected; the runtime adapter must call setSandboxRequire() during bootstrap'); -} - -let sandboxRequire: SandboxRequire = defaultSandboxRequire; - -export function setSandboxRequire(newRequire: SandboxRequire): void { - sandboxRequire = newRequire; -} - -/** - * Extra globals bound into the app's eval shell on top of the common ones - * (`exports`, `module`, `require`, `console`, `globalThis`). Node needs none; - * Deno injects a `Buffer` and shadows `Deno` with `undefined`. Injecting them - * as data keeps the eval-shell skeleton single-source. - */ -type SandboxGlobals = Record; - -let sandboxGlobals: SandboxGlobals = {}; - -export function setSandboxGlobals(globals: SandboxGlobals): void { - sandboxGlobals = globals; -} - -// As the apps are bundled, the only times they will call require are -// 1. To require native modules -// 2. To require external npm packages we may provide -// 3. To require apps-engine files -function buildRequire(): (module: string) => unknown { - return (module: string): unknown => { - // Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto' - const normalized = module.replace('node:', ''); - - if (ALLOWED_NATIVE_MODULES.includes(normalized)) { - return sandboxRequire(`node:${normalized}`); - } - - if (ALLOWED_EXTERNAL_MODULES.includes(module)) { - return sandboxRequire(`npm:${module}`); - } - - if (module.startsWith('@rocket.chat/apps-engine')) { - // Our `require` function knows how to handle these - return sandboxRequire(module); - } - - throw new Error(`Module ${module} is not allowed`); - }; -} - -function wrapAppCode(code: string): (require: SandboxRequire) => Promise> { - const globals = sandboxGlobals; - // The common globals are bound by name; any platform-specific extras are - // spread in by name from the injected `sandboxGlobals`, so the shell - // skeleton stays identical across runtimes. - const extraNames = Object.keys(globals); - const extraParams = extraNames.length ? `,${extraNames.join(',')}` : ''; - const extraArgs = extraNames.map((name) => `,__globals[${JSON.stringify(name)}]`).join(''); - - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- This is the reason we run in a separate process - const fn = new Function( - 'require', - '__globals', - ` - const exports = {}; - const module = { exports }; - const _error = console.error.bind(console); - const _console = { - log: _error, - error: _error, - debug: _error, - info: _error, - warn: _error, - }; - - const result = (async (exports,module,require,console,globalThis${extraParams}) => { - ${code}; - })(exports,module,require,_console,undefined${extraArgs}); - - return result.then(() => module.exports);`, - ) as (require: SandboxRequire, globals: SandboxGlobals) => Promise>; - - return (require: SandboxRequire) => fn(require, globals); -} - -export default async function handleConstructApp(request: RequestContext): Promise { - const { params } = request; - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [appPackage] = params as [IParseAppPackageResult]; - - if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - AppObjectRegistry.set('id', appPackage.info.id); - const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]); - - const require = buildRequire(); - const exports = await wrapAppCode(source)(require); - - // This is the same naive logic we've been using in the App Compiler - // Applying the correct type here is quite difficult because of the dynamic nature of the code - // deno-lint-ignore no-explicit-any - const appClass = Object.values(exports)[0] as any; - - const app = new appClass(appPackage.info, request.context.logger, AppAccessorsInstance.getDefaultAppAccessors()); - - if (typeof app.getName !== 'function') { - throw new Error('App must contain a getName function'); - } - - if (typeof app.getNameSlug !== 'function') { - throw new Error('App must contain a getNameSlug function'); - } - - if (typeof app.getVersion !== 'function') { - throw new Error('App must contain a getVersion function'); - } - - if (typeof app.getID !== 'function') { - throw new Error('App must contain a getID function'); - } - - if (typeof app.getDescription !== 'function') { - throw new Error('App must contain a getDescription function'); - } - - if (typeof app.getRequiredApiVersion !== 'function') { - throw new Error('App must contain a getRequiredApiVersion function'); - } - - AppObjectRegistry.set('app', app); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts b/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts deleted file mode 100644 index 38635a02c60ae..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; - -export default function handleGetStatus(): Promise { - const app = AppObjectRegistry.get('app'); - - if (typeof app?.getStatus !== 'function') { - throw new Error('App must contain a getStatus function', { - cause: 'invalid_app', - }); - } - - return app.getStatus(); -} diff --git a/packages/apps/deno-runtime/handlers/app/handleInitialize.ts b/packages/apps/deno-runtime/handlers/app/handleInitialize.ts deleted file mode 100644 index 136e93d2af46a..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleInitialize.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleInitialize(request: RequestContext): Promise { - const app = AppObjectRegistry.get('app'); - - if (typeof app?.initialize !== 'function') { - throw new Error('App must contain an initialize function', { - cause: 'invalid_app', - }); - } - - await app.initialize.call( - wrapAppForRequest(app, request), - AppAccessorsInstance.getConfigurationExtend(), - AppAccessorsInstance.getEnvironmentRead() - ); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts b/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts deleted file mode 100644 index f84e6a34abb5e..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleOnDisable(request: RequestContext): Promise { - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onDisable !== 'function') { - throw new Error('App must contain an onDisable function', { - cause: 'invalid_app', - }); - } - - await app.onDisable.call(wrapAppForRequest(app, request), AppAccessorsInstance.getConfigurationModify()); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts b/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts deleted file mode 100644 index f36eaecf951b2..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default function handleOnEnable(request: RequestContext): Promise { - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onEnable !== 'function') { - throw new Error('App must contain an onEnable function', { - cause: 'invalid_app', - }); - } - - return app.onEnable.call( - wrapAppForRequest(app, request), - AppAccessorsInstance.getEnvironmentRead(), - AppAccessorsInstance.getConfigurationModify() - ); -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts b/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts deleted file mode 100644 index ca1bbfd2f56f7..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleOnInstall(request: RequestContext): Promise { - const { params } = request; - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onInstall !== 'function') { - throw new Error('App must contain an onInstall function', { - cause: 'invalid_app', - }); - } - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [context] = params as [Record]; - - await app.onInstall.call( - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - AppAccessorsInstance.getModifier(), - ); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts b/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts deleted file mode 100644 index 97a99e151eb02..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default function handleOnPreSettingUpdate(request: RequestContext): Promise { - const { params } = request; - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onPreSettingUpdate !== 'function') { - throw new Error('App must contain an onPreSettingUpdate function', { - cause: 'invalid_app', - }); - } - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [setting] = params as [Record]; - - return app.onPreSettingUpdate.call( - wrapAppForRequest(app, request), - setting, - AppAccessorsInstance.getConfigurationModify(), - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - ); -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts b/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts deleted file mode 100644 index cdd5d93669353..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleOnSettingUpdated(request: RequestContext): Promise { - const { params } = request; - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onSettingUpdated !== 'function') { - throw new Error('App must contain an onSettingUpdated function', { - cause: 'invalid_app', - }); - } - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [setting] = params as [Record]; - - await app.onSettingUpdated.call( - wrapAppForRequest(app, request), - setting, - AppAccessorsInstance.getConfigurationModify(), - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - ); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts b/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts deleted file mode 100644 index 3008f822c9611..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleOnUninstall(request: RequestContext): Promise { - const { params } = request; - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onUninstall !== 'function') { - throw new Error('App must contain an onUninstall function', { - cause: 'invalid_app', - }); - } - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [context] = params as [Record]; - - await app.onUninstall.call( - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - AppAccessorsInstance.getModifier(), - ); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts b/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts deleted file mode 100644 index c4651c2fb25b0..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleOnUpdate(request: RequestContext): Promise { - const { params } = request; - const app = AppObjectRegistry.get('app'); - - if (typeof app?.onUpdate !== 'function') { - throw new Error('App must contain an onUpdate function', { - cause: 'invalid_app', - }); - } - - if (!Array.isArray(params)) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [context] = params as [Record]; - - await app.onUpdate.call( - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - AppAccessorsInstance.getModifier(), - ); - - return true; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts b/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts deleted file mode 100644 index fdae2a87f5b4d..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleSetStatus(request: RequestContext): Promise { - const { params } = request; - - if (!Array.isArray(params) || !Object.values(AppStatus).includes(params[0])) { - throw new Error('Invalid params', { cause: 'invalid_param_type' }); - } - - const [status] = params as [AppStatus]; - - const app = AppObjectRegistry.get('app'); - - if (!app || typeof app['setStatus'] !== 'function') { - throw new Error('App must contain a setStatus function', { - cause: 'invalid_app', - }); - } - - await app['setStatus'].call(wrapAppForRequest(app, request), status); - - return null; -} diff --git a/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts b/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts deleted file mode 100644 index 69b698532da2a..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { readFile } from 'node:fs/promises'; - -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException'; -import type { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext'; -import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; -import type { Defined } from 'jsonrpc-lite'; -import { JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; -import { assertAppAvailable, assertHandlerFunction, isPlainObject } from '../lib/assertions'; - -export const uploadEvents = ['executePreFileUpload'] as const; - -function assertIsUpload(v: unknown): asserts v is IUploadDetails { - if (isPlainObject(v) && !!v.rid && (!!v.userId || !!v.visitorToken)) return; - - throw JsonRpcError.invalidParams({ err: `Invalid 'file' parameter. Expected IUploadDetails, got`, value: v }); -} - -function assertString(v: unknown): asserts v is string { - if (v && typeof v === 'string') return; - - throw JsonRpcError.invalidParams({ err: `Invalid 'path' parameter. Expected string, got`, value: v }); -} - -export default async function handleUploadEvents(request: RequestContext): Promise { - const { method: rawMethod, params } = request as { - method: `app:${(typeof uploadEvents)[number]}`; - params: [{ file?: IUploadDetails; path?: string }]; - }; - const [, method] = rawMethod.split(':') as ['app', (typeof uploadEvents)[number]]; - - try { - const [{ file, path }] = params; - - const app = AppObjectRegistry.get('app'); - const handlerFunction = app?.[method as keyof App] as unknown; - - assertAppAvailable(app); - assertHandlerFunction(handlerFunction); - assertIsUpload(file); - assertString(path); - - let context: IFileUploadContext; - - switch (method) { - case 'executePreFileUpload': { - const fileContents = await readFile(path); - context = { file, content: Buffer.from(fileContents) }; - break; - } - } - - return await handlerFunction.call( - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - AppAccessorsInstance.getModifier(), - ); - } catch (e) { - if (e?.name === AppsEngineException.name) { - return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); - } - - if (e instanceof JsonRpcError) { - return e; - } - - return JsonRpcError.internalError({ - err: e.message, - ...(e.code && { code: e.code }), - }); - } -} diff --git a/packages/apps/deno-runtime/handlers/app/handler.ts b/packages/apps/deno-runtime/handlers/app/handler.ts deleted file mode 100644 index b873bd4b09de0..0000000000000 --- a/packages/apps/deno-runtime/handlers/app/handler.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import handleConstructApp from './construct'; -import handleInitialize from './handleInitialize'; -import handleGetStatus from './handleGetStatus'; -import handleSetStatus from './handleSetStatus'; -import handleOnEnable from './handleOnEnable'; -import handleOnInstall from './handleOnInstall'; -import handleOnDisable from './handleOnDisable'; -import handleOnUninstall from './handleOnUninstall'; -import handleOnPreSettingUpdate from './handleOnPreSettingUpdate'; -import handleOnSettingUpdated from './handleOnSettingUpdated'; -import handleOnUpdate from './handleOnUpdate'; -import handleUploadEvents, { uploadEvents } from './handleUploadEvents'; -import { isOneOf } from '../lib/assertions'; -import handleListener from '../listener/handler'; -import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler'; -import { RequestContext } from '../../lib/requestContext'; - -export default async function handleApp(request: RequestContext): Promise { - const { method } = request; - const { logger } = request.context; - const [, appMethod] = method.split(':'); - - try { - // We don't want the getStatus method to generate logs, so we handle it separately - if (appMethod === 'getStatus') { - return await handleGetStatus(); - } - - logger.debug({ msg: `A method is being called...`, appMethod }); - - const formatResult = (result: Defined | JsonRpcError): Defined | JsonRpcError => { - if (result instanceof JsonRpcError) { - logger.debug({ - msg: `'${appMethod}' was unsuccessful.`, - appMethod, - err: result, - errorMessage: result.message, - }); - } else { - logger.debug({ - msg: `'${appMethod}' was successfully called! The result is:`, - appMethod, - result, - }); - } - - return result; - }; - - let result: Promise | undefined = undefined; - - if (isOneOf(appMethod, uploadEvents)) { - result = handleUploadEvents(request); - } else if (isOneOf(appMethod, uikitInteractions)) { - result = handleUIKitInteraction(request); - } else if (appMethod.startsWith('check') || appMethod.startsWith('execute')) { - result = handleListener(request); - } - - switch (appMethod) { - case 'construct': - result = handleConstructApp(request); - break; - case 'initialize': - result = handleInitialize(request); - break; - case 'setStatus': - result = handleSetStatus(request); - break; - case 'onEnable': - result = handleOnEnable(request); - break; - case 'onDisable': - result = handleOnDisable(request); - break; - case 'onInstall': - result = handleOnInstall(request); - break; - case 'onUninstall': - result = handleOnUninstall(request); - break; - case 'onPreSettingUpdate': - result = handleOnPreSettingUpdate(request); - break; - case 'onSettingUpdated': - result = handleOnSettingUpdated(request); - break; - case 'onUpdate': - result = handleOnUpdate(request); - break; - } - - if (typeof result === 'undefined') { - throw new JsonRpcError(`Unknown method "${appMethod}"`, -32601); - } - - return await result.then(formatResult); - } catch (e: unknown) { - if (!(e instanceof Error)) { - return new JsonRpcError('Unknown error', -32000, e); - } - - if ((e.cause as string)?.includes('invalid_param_type')) { - return JsonRpcError.invalidParams(null); - } - - if ((e.cause as string)?.includes('invalid_app')) { - return JsonRpcError.internalError({ message: 'App unavailable' }); - } - - return new JsonRpcError(e.message, -32000, e); - } -} diff --git a/packages/apps/deno-runtime/handlers/lib/assertions.ts b/packages/apps/deno-runtime/handlers/lib/assertions.ts deleted file mode 100644 index d2668840fb4bc..0000000000000 --- a/packages/apps/deno-runtime/handlers/lib/assertions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import { JsonRpcError } from 'jsonrpc-lite'; - -/** - * Known failures that can happen in the runtime. - * - * DRT = Deno RunTime - */ -export const Errors = { - DRT_APP_NOT_AVAILABLE: 'DRT_APP_NOT_AVAILABLE', - DRT_EVENT_HANDLER_FUNCTION_MISSING: 'DRT_EVENT_HANDLER_FUNCTION_MISSING', -} - -export function isRecord(v: unknown): v is Record { - return !!v && typeof v === 'object' && !Array.isArray(v); -} - -export function isPlainObject(v: unknown): v is Record { - if (!isRecord(v)) { - return false; - } - - const prototype = Object.getPrototypeOf(v); - - return prototype === null || prototype.constructor === Object; -} - -/** - * Type guard function to check if a value is included in a readonly array - * and narrow its type accordingly. - */ -export function isOneOf(value: unknown, array: readonly T[]): value is T { - return array.includes(value as T); -} - -export function isApp(v: unknown): v is App { - return !!v && typeof (v as App)['extendConfiguration'] === 'function'; -} - -export function assertAppAvailable(v: unknown): asserts v is App { - if (isApp(v)) return; - - throw JsonRpcError.internalError({ err: 'App object not available', code: Errors.DRT_APP_NOT_AVAILABLE }); -} - -// deno-lint-ignore ban-types -- Function is the best we can do at this time -export function assertHandlerFunction(v: unknown): asserts v is Function { - if (v instanceof Function) return; - - throw JsonRpcError.internalError({ err: `Expected handler function, got ${v}`, code: Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING }); -} diff --git a/packages/apps/deno-runtime/handlers/listener/handler.ts b/packages/apps/deno-runtime/handlers/listener/handler.ts deleted file mode 100644 index 59da5f7e0e5e5..0000000000000 --- a/packages/apps/deno-runtime/handlers/listener/handler.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender'; -import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender'; -import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder'; -import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder'; -import { AppAccessors, AppAccessorsInstance } from '../../lib/accessors/mod'; -import createRoom from '../../lib/roomFactory'; -import { Room } from '../../lib/room'; -import { RequestContext } from '../../lib/requestContext'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export default async function handleListener(request: RequestContext): Promise { - const { method, params } = request; - const [, evtInterface] = method.split(':'); - const app = AppObjectRegistry.get('app'); - - const eventExecutor = app?.[evtInterface as keyof App]; - - if (!app || typeof eventExecutor !== 'function') { - return JsonRpcError.methodNotFound({ - message: 'Invalid event interface called on app', - }); - } - - if (!Array.isArray(params) || params.length < 1 || params.length > 2) { - return JsonRpcError.invalidParams(null); - } - - try { - const args = parseArgs({ AppAccessorsInstance }, evtInterface, params); - return await (eventExecutor as (...args: unknown[]) => Promise).apply(wrapAppForRequest(app, request), args); - } catch (e) { - if (e instanceof JsonRpcError) { - return e; - } - - if (e instanceof AppsEngineException) { - return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); - } - - return JsonRpcError.internalError({ message: (e as { message?: string }).message }); - } -} - -export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] { - const { AppAccessorsInstance } = deps; - /** - * param1 is the context for the event handler execution - * param2 is an optional extra content that some hanlers require - */ - const [param1, param2] = params as [unknown, unknown]; - - if (!param1) { - throw JsonRpcError.invalidParams(null); - } - - let context = param1; - - if (evtMethod.includes('Message')) { - context = hydrateMessageObjects(context) as Record; - } else if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) { - (context as Record).room = createRoom((context as Record).room as IRoom, AppAccessorsInstance.getSenderFn()); - } else if (evtMethod.includes('PreRoom')) { - context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn()); - } - - const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()]; - - // "check" events will only go this far - (context, reader, http) - if (evtMethod.startsWith('check')) { - // "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext) - if (param2) { - args.push(hydrateMessageObjects(param2)); - } - - return args; - } - - // From this point on, all events will require (reader, http, persistence) injected - args.push(AppAccessorsInstance.getPersistence()); - - // "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence) - if (evtMethod.endsWith('Extend')) { - if (evtMethod.includes('Message')) { - args.splice(1, 0, new MessageExtender(param1 as IMessage)); - } else if (evtMethod.includes('Room')) { - args.splice(1, 0, new RoomExtender(param1 as IRoom)); - } - - return args; - } - - // "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence) - if (evtMethod.endsWith('Modify')) { - if (evtMethod.includes('Message')) { - args.splice(1, 0, new MessageBuilder(param1 as IMessage)); - } else if (evtMethod.includes('Room')) { - args.splice(1, 0, new RoomBuilder(param1 as IRoom)); - } - - return args; - } - - // From this point on, all events will require (reader, http, persistence, modifier) injected - args.push(AppAccessorsInstance.getModifier()); - - // This guy gets an extra one - if (evtMethod === 'executePostMessageDeleted') { - if (!param2) { - throw JsonRpcError.invalidParams(null); - } - - args.push(hydrateMessageObjects(param2)); - } - - return args; -} - -/** - * Hydrate the context object with the correct IMessage - * - * Some information is lost upon serializing the data from listeners through the pipes, - * so here we hydrate the complete object as necessary - */ -function hydrateMessageObjects(context: unknown): unknown { - if (objectIsRawMessage(context)) { - context.room = createRoom(context.room as IRoom, AppAccessorsInstance.getSenderFn()); - } else if ((context as Record)?.message) { - (context as Record).message = hydrateMessageObjects((context as Record).message); - } - - return context; -} - -function objectIsRawMessage(value: unknown): value is IMessage { - if (!value) return false; - - const { id, room, sender, createdAt } = value as Record; - - // Check if we have the fields of a message and the room hasn't already been hydrated - return !!(id && room && sender && createdAt) && !(room instanceof Room); -} diff --git a/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts b/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts deleted file mode 100644 index 4a74dd6788a7c..0000000000000 --- a/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider'; -import { JsonRpcError, Defined } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; -import { AppAccessorsInstance } from '../lib/accessors/mod'; -import { RequestContext } from '../lib/requestContext'; -import { wrapComposedApp } from '../lib/wrapAppForRequest'; - -export default async function outboundMessageHandler(request: RequestContext): Promise { - const { method: call, params } = request; - const [, providerName, methodName] = call.split(':'); - - const provider = AppObjectRegistry.get(`outboundCommunication:${providerName}`); - - if (!provider) { - return new JsonRpcError('error-invalid-provider', -32000); - } - - const method = provider[methodName as keyof IOutboundMessageProviders]; - const { logger } = request.context; - const args = (params as Array) ?? []; - - try { - logger.debug(`Executing ${methodName} on outbound communication provider...`); - - // deno-lint-ignore ban-types - return await (method as Function).apply(wrapComposedApp(provider, request), [ - ...args, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getModifier(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - ]); - } catch (e) { - return new JsonRpcError(e.message, -32000); - } -} diff --git a/packages/apps/deno-runtime/handlers/scheduler-handler.ts b/packages/apps/deno-runtime/handlers/scheduler-handler.ts deleted file mode 100644 index 88e53b7af5ac2..0000000000000 --- a/packages/apps/deno-runtime/handlers/scheduler-handler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; -import { AppAccessorsInstance } from '../lib/accessors/mod'; -import { RequestContext } from '../lib/requestContext'; -import { wrapAppForRequest } from '../lib/wrapAppForRequest'; -import { assertAppAvailable } from './lib/assertions'; - -export default async function handleScheduler(request: RequestContext): Promise { - const { method, params } = request; - const { logger } = request.context; - - const [, processorId] = method.split(':'); - if (!Array.isArray(params)) { - return JsonRpcError.invalidParams({ message: 'Invalid params' }); - } - - const [context] = params as [Record]; - - // AppSchedulerManager will append the appId to the processor name to avoid conflicts - const processor = AppObjectRegistry.get(`scheduler:${processorId}`); - - if (!processor) { - return JsonRpcError.methodNotFound({ - message: `Could not find processor for method ${method}`, - }); - } - - logger.debug({ msg: 'Job processor is being executed...', processorId: processor.id }); - - const app = AppObjectRegistry.get('app'); - - try { - assertAppAvailable(app); - - await processor.processor.call( - // Processor registration doesn't require the App dev to instantiate a class passing - // a reference to an App object, so we don't have a good way of hijacking the Logger - // we need. - // The only way we have to provide a durable Logger instance for the processor is by - // binding its execution to the proxied App reference itself. Unfortunately, the API - // ends up being opaque, but there isn't much we can do for now. - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getModifier(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - ); - - logger.debug({ msg: 'Job processor was successfully executed', processorId: processor.id }); - - return null; - } catch (err) { - logger.error({ err, msg: 'Job processor was unsuccessful', processorId: processor.id }); - - if (err instanceof JsonRpcError) { - return err; - } - - return JsonRpcError.internalError({ message: err.message }); - } -} diff --git a/packages/apps/deno-runtime/handlers/slashcommand-handler.ts b/packages/apps/deno-runtime/handlers/slashcommand-handler.ts deleted file mode 100644 index b796992a23948..0000000000000 --- a/packages/apps/deno-runtime/handlers/slashcommand-handler.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand'; -import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; -import { AppAccessors, AppAccessorsInstance } from '../lib/accessors/mod'; -import createRoom from '../lib/roomFactory'; -import { RequestContext } from '../lib/requestContext'; -import { wrapComposedApp } from '../lib/wrapAppForRequest'; - -export default async function slashCommandHandler(request: RequestContext): Promise { - const { method: call, params } = request; - const { logger } = request.context; - - const [, commandName, method] = call.split(':'); - - const command = AppObjectRegistry.get(`slashcommand:${commandName}`); - - if (!command) { - return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000); - } - - let result: Awaited> | Awaited>; - - logger.debug({ msg: `Command is being executed...`, commandName, method, params }); - - try { - if (method === 'executor' || method === 'previewer') { - result = await handleExecutor({ AppAccessorsInstance, request }, command, method, params); - } else if (method === 'executePreviewItem') { - result = await handlePreviewItem({ AppAccessorsInstance, request }, command, params); - } else { - return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); - } - - logger.debug({ msg: `Command was successfully executed.`, commandName, method }); - } catch (error) { - logger.debug({ msg: `Command was unsuccessful.`, commandName, method, err: error }); - - return new JsonRpcError(error.message, -32000); - } - - return result; -} - -type Deps = { - AppAccessorsInstance: AppAccessors, - request: RequestContext; -} - -/** - * @param deps Dependencies that need to be injected into the slashcommand - * @param command The slashcommand that is being executed - * @param method The method that is being executed - * @param params The parameters that are being passed to the method - */ -export function handleExecutor(deps: Deps, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) { - const executor = command[method]; - - if (typeof executor !== 'function') { - throw new Error(`Method ${method} not found on slashcommand ${command.command}`); - } - - if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { - throw new Error(`First parameter must be an object`); - } - - const { sender, room, params: args, threadId, triggerId } = params[0] as Record; - - const context = new SlashCommandContext( - sender as SlashCommandContext['sender'], - createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), - args as SlashCommandContext['params'], - threadId as SlashCommandContext['threadId'], - triggerId as SlashCommandContext['triggerId'], - ); - - return executor.apply(wrapComposedApp(command, deps.request), [ - context, - deps.AppAccessorsInstance.getReader(), - deps.AppAccessorsInstance.getModifier(), - deps.AppAccessorsInstance.getHttp(), - deps.AppAccessorsInstance.getPersistence(), - ]); -} - -/** - * @param deps Dependencies that need to be injected into the slashcommand - * @param command The slashcommand that is being executed - * @param params The parameters that are being passed to the method - */ -export function handlePreviewItem(deps: Deps, command: ISlashCommand, params: unknown) { - if (typeof command.executePreviewItem !== 'function') { - throw new Error(`Method not found on slashcommand ${command.command}`); - } - - if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { - throw new Error(`First parameter must be an object`); - } - - const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record, Record]; - - const context = new SlashCommandContext( - sender as SlashCommandContext['sender'], - createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), - args as SlashCommandContext['params'], - threadId as SlashCommandContext['threadId'], - triggerId as SlashCommandContext['triggerId'], - ); - - return command.executePreviewItem.call( - wrapComposedApp(command, deps.request), - previewItem, - context, - deps.AppAccessorsInstance.getReader(), - deps.AppAccessorsInstance.getModifier(), - deps.AppAccessorsInstance.getHttp(), - deps.AppAccessorsInstance.getPersistence(), - ); -} diff --git a/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts deleted file mode 100644 index 501174cf36d8e..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; -import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/assert_instance_of.ts'; -import { JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import apiHandler from '../api-handler'; -import { createMockRequest } from './helpers/mod'; - -describe('handlers > api', () => { - const mockEndpoint: IApiEndpoint = { - path: '/test', - // deno-lint-ignore no-unused-vars - get: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), - // deno-lint-ignore no-unused-vars - post: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), - // deno-lint-ignore no-unused-vars - put: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => { - throw new Error('Method execution error example'); - }, - }; - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('api:/test', mockEndpoint); - }); - - it('correctly handles execution of an api endpoint method GET', async () => { - const _spy = spy(mockEndpoint, 'get'); - - const result = await apiHandler(createMockRequest({ method: 'api:/test:get', params: ['request', 'endpointInfo'] })); - - assertEquals(result, 'ok'); - assertEquals(_spy.calls[0].args.length, 6); - assertEquals(_spy.calls[0].args[0], 'request'); - assertEquals(_spy.calls[0].args[1], 'endpointInfo'); - }); - - it('correctly handles execution of an api endpoint method POST', async () => { - const _spy = spy(mockEndpoint, 'post'); - - const result = await apiHandler(createMockRequest({ method: 'api:/test:post', params: ['request', 'endpointInfo'] })); - - assertEquals(result, 'ok'); - assertEquals(_spy.calls[0].args.length, 6); - assertEquals(_spy.calls[0].args[0], 'request'); - assertEquals(_spy.calls[0].args[1], 'endpointInfo'); - }); - - it('correctly handles an error if the method not exists for the selected endpoint', async () => { - const result = await apiHandler(createMockRequest({ method: `api:/test:delete`, params: ['request', 'endpointInfo'] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: `/test's delete not exists`, - code: -32000, - }); - }); - - it('correctly handles an error if endpoint not exists', async () => { - const result = await apiHandler(createMockRequest({ method: `api:/error:get`, params: ['request', 'endpointInfo'] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: `Endpoint /error not found`, - code: -32000, - }); - }); - - it('correctly handles an error if the method execution fails', async () => { - const result = await apiHandler(createMockRequest({ method: `api:/test:put`, params: ['request', 'endpointInfo'] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: `Method execution error example`, - code: -32000, - }); - }); - - it('correctly handles dynamic paths with parameters (e.g., webhook/:event)', async () => { - const mockDynamicEndpoint: IApiEndpoint = { - path: 'webhook/:event', - // deno-lint-ignore no-unused-vars - post: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('webhook handled'), - }; - - AppObjectRegistry.set('api:webhook/:event', mockDynamicEndpoint); - - const _spy = spy(mockDynamicEndpoint, 'post'); - - const result = await apiHandler(createMockRequest({ method: 'api:webhook/:event:post', params: ['request', 'endpointInfo'] })); - - assertEquals(result, 'webhook handled'); - assertEquals(_spy.calls[0].args.length, 6); - assertEquals(_spy.calls[0].args[0], 'request'); - assertEquals(_spy.calls[0].args[1], 'endpointInfo'); - }); - - it('correctly handles paths with multiple segments and colons', async () => { - const mockComplexEndpoint: IApiEndpoint = { - path: 'api/v1/:resource/:id', - // deno-lint-ignore no-unused-vars - get: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('complex path'), - }; - - AppObjectRegistry.set('api:api/v1/:resource/:id', mockComplexEndpoint); - - const _spy = spy(mockComplexEndpoint, 'get'); - - const result = await apiHandler(createMockRequest({ method: 'api:api/v1/:resource/:id:get', params: ['request', 'endpointInfo'] })); - - assertEquals(result, 'complex path'); - assertEquals(_spy.calls[0].args.length, 6); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts b/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts deleted file mode 100644 index ce4f4df19a030..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { Logger } from '../../../lib/logger'; -import { RequestDescriptor } from '../../../lib/messenger'; -import { RequestContext } from '../../../lib/requestContext'; - -export function createMockRequest({ method, params }: RequestDescriptor): RequestContext { - return { - jsonrpc: '2.0', - id: 1, - method, - params, - context: { - logger: new Logger(method), - }, - serialize: () => '', - } -} - -export function createMockApp(): App { - return { - extendConfiguration: () => {}, - getID: () => 'mockApp', - getLogger: () => ({ - debug: () => {}, - error: () => {}, - }), - } as unknown as App; -} diff --git a/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts deleted file mode 100644 index 6b22d1acdea64..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals, assertInstanceOf, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; - -import { parseArgs } from '../listener/handler'; -import { AppAccessors } from '../../lib/accessors/mod'; -import { Room } from '../../lib/room'; -import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender'; -import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender'; -import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder'; -import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder'; - -describe('handlers > listeners', () => { - const mockAppAccessors = { - getReader: () => ({ __type: 'reader' }), - getHttp: () => ({ __type: 'http' }), - getModifier: () => ({ __type: 'modifier' }), - getPersistence: () => ({ __type: 'persistence' }), - getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), - } as unknown as AppAccessors; - - it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => { - const evtMethod = 'checkPreMessageSentPrevent'; - // For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario - const evtArgs = [{ __type: 'context' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 3); - assertEquals(params[0], { __type: 'context' }); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - }); - - it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => { - const evtMethod = 'checkPostMessageDeleted'; - // For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario, - // and the extraContext will provide further information such the user who deleted the message - const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 4); - assertEquals(params[0], { __type: 'context' }); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - assertEquals(params[3], { __type: 'extraContext' }); - }); - - it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => { - const evtMethod = 'checkPreRoomCreateExtend'; - // For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario - const evtArgs = [ - { - id: 'fake', - type: 'fake', - slugifiedName: 'fake', - creator: 'fake', - createdAt: Date.now(), - }, - ]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 3); - - assertInstanceOf(params[0], Room); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => { - const evtMethod = 'executePreMessageSentExtend'; - // For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario - const evtArgs = [{ __type: 'context' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - // Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals - assertObjectMatch(params[0] as Record, { - __type: 'context', - }); - assertInstanceOf(params[1], MessageExtender); - assertEquals(params[2], { __type: 'reader' }); - assertEquals(params[3], { __type: 'http' }); - assertEquals(params[4], { __type: 'persistence' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => { - const evtMethod = 'executePreRoomCreateExtend'; - // For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario - const evtArgs = [{ __type: 'context' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - // Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals - assertObjectMatch(params[0] as Record, { - __type: 'context', - }); - assertInstanceOf(params[1], RoomExtender); - assertEquals(params[2], { __type: 'reader' }); - assertEquals(params[3], { __type: 'http' }); - assertEquals(params[4], { __type: 'persistence' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => { - const evtMethod = 'executePreMessageSentModify'; - // For the 'executePreMessageSentModify' method, the context will be a message in a real scenario - const evtArgs = [{ __type: 'context' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - // Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals - assertObjectMatch(params[0] as Record, { - __type: 'context', - }); - assertInstanceOf(params[1], MessageBuilder); - assertEquals(params[2], { __type: 'reader' }); - assertEquals(params[3], { __type: 'http' }); - assertEquals(params[4], { __type: 'persistence' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => { - const evtMethod = 'executePreRoomCreateModify'; - // For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario - const evtArgs = [{ __type: 'context' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - // Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals - assertObjectMatch(params[0] as Record, { - __type: 'context', - }); - assertInstanceOf(params[1], RoomBuilder); - assertEquals(params[2], { __type: 'reader' }); - assertEquals(params[3], { __type: 'http' }); - assertEquals(params[4], { __type: 'persistence' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => { - const evtMethod = 'executePostRoomUserJoined'; - // For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario - const room = { - id: 'fake', - type: 'fake', - slugifiedName: 'fake', - creator: 'fake', - createdAt: Date.now(), - }; - - const evtArgs = [{ __type: 'context', room }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - assertInstanceOf((params[0] as any).room, Room); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - assertEquals(params[3], { __type: 'persistence' }); - assertEquals(params[4], { __type: 'modifier' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => { - const evtMethod = 'executePostRoomUserLeave'; - // For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario - const room = { - id: 'fake', - type: 'fake', - slugifiedName: 'fake', - creator: 'fake', - createdAt: Date.now(), - }; - - const evtArgs = [{ __type: 'context', room }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - assertInstanceOf((params[0] as any).room, Room); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - assertEquals(params[3], { __type: 'persistence' }); - assertEquals(params[4], { __type: 'modifier' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => { - const evtMethod = 'executePostMessageDeleted'; - // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario - const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 6); - assertEquals(params[0], { __type: 'context' }); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - assertEquals(params[3], { __type: 'persistence' }); - assertEquals(params[4], { __type: 'modifier' }); - assertEquals(params[5], { __type: 'extraContext' }); - }); - - it('correctly parses the arguments for a request to trigger the "executePostMessageSent" method', () => { - const evtMethod = 'executePostMessageSent'; - // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario - const evtArgs = [ - { - id: 'fake', - sender: 'fake', - createdAt: Date.now(), - room: { - id: 'fake-room', - type: 'fake', - slugifiedName: 'fake', - creator: 'fake', - createdAt: Date.now(), - }, - }, - ]; - - const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); - - assertEquals(params.length, 5); - assertObjectMatch(params[0] as Record, { id: 'fake' }); - assertInstanceOf((params[0] as any).room, Room); - assertEquals(params[1], { __type: 'reader' }); - assertEquals(params[2], { __type: 'http' }); - assertEquals(params[3], { __type: 'persistence' }); - assertEquals(params[4], { __type: 'modifier' }); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts deleted file mode 100644 index 94037b2f96427..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessors } from '../../lib/accessors/mod'; -import handleScheduler from '../scheduler-handler'; -import { createMockApp, createMockRequest } from './helpers/mod'; - -describe('handlers > scheduler', () => { - const mockAppAccessors = new AppAccessors(() => - Promise.resolve({ - id: 'mockId', - result: {}, - jsonrpc: '2.0', - serialize: () => '', - }) - ); - - const mockApp = createMockApp(); - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('app', mockApp); - mockAppAccessors.getConfigurationExtend().scheduler.registerProcessors([ - { - id: 'mockId', - processor: () => Promise.resolve('it works!'), - }, - ]); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('correctly executes a request to a processor', async () => { - const result = await handleScheduler(createMockRequest({ method: 'scheduler:mockId', params: [{}] })); - - assertEquals(result, null); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts deleted file mode 100644 index e1a8df72ef96a..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals, assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessors } from '../../lib/accessors/mod'; -import { handleExecutor, handlePreviewItem } from '../slashcommand-handler'; -import { Room } from '../../lib/room'; -import { createMockRequest } from './helpers/mod'; - -describe('handlers > slashcommand', () => { - const mockAppAccessors = { - getReader: () => ({ __type: 'reader' }), - getHttp: () => ({ __type: 'http' }), - getModifier: () => ({ __type: 'modifier' }), - getPersistence: () => ({ __type: 'persistence' }), - getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), - } as unknown as AppAccessors; - - const mockCommandExecutorOnly = { - command: 'executor-only', - i18nParamsExample: 'test', - i18nDescription: 'test', - providesPreview: false, - // deno-lint-ignore no-unused-vars - async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, - }; - - const mockCommandExecutorAndPreview = { - command: 'executor-and-preview', - i18nParamsExample: 'test', - i18nDescription: 'test', - providesPreview: true, - // deno-lint-ignore no-unused-vars - async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, - // deno-lint-ignore no-unused-vars - async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, - // deno-lint-ignore no-unused-vars - async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, - }; - - const mockCommandPreviewWithNoExecutor = { - command: 'preview-with-no-executor', - i18nParamsExample: 'test', - i18nDescription: 'test', - providesPreview: true, - // deno-lint-ignore no-unused-vars - async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, - // deno-lint-ignore no-unused-vars - async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, - }; - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly); - AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview); - AppObjectRegistry.set('slashcommand:preview-with-no-executor', mockCommandPreviewWithNoExecutor); - }); - - it('correctly handles execution of a slash command', async () => { - const mockContext = { - sender: { __type: 'sender' }, - room: { __type: 'room' }, - params: { __type: 'params' }, - threadId: 'threadId', - triggerId: 'triggerId', - }; - - const _spy = spy(mockCommandExecutorOnly, 'executor'); - - const mockRequest = createMockRequest({ method: 'slashcommand:executor-only:executor', params: [mockContext] }); - - await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorOnly, 'executor', [mockContext]); - - const context = _spy.calls[0].args[0]; - - assertInstanceOf(context.getRoom(), Room); - assertEquals(context.getSender(), { __type: 'sender' }); - assertEquals(context.getArguments(), { __type: 'params' }); - assertEquals(context.getThreadId(), 'threadId'); - assertEquals(context.getTriggerId(), 'triggerId'); - - assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); - assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); - assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); - assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); - - _spy.restore(); - }); - - it('correctly handles execution of a slash command previewer', async () => { - const mockContext = { - sender: { __type: 'sender' }, - room: { __type: 'room' }, - params: { __type: 'params' }, - threadId: 'threadId', - triggerId: 'triggerId', - }; - - const _spy = spy(mockCommandExecutorAndPreview, 'previewer'); - - const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:previewer', params: [mockContext] }); - - await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, 'previewer', [mockContext]); - - const context = _spy.calls[0].args[0]; - - assertInstanceOf(context.getRoom(), Room); - assertEquals(context.getSender(), { __type: 'sender' }); - assertEquals(context.getArguments(), { __type: 'params' }); - assertEquals(context.getThreadId(), 'threadId'); - assertEquals(context.getTriggerId(), 'triggerId'); - - assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); - assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); - assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); - assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); - - _spy.restore(); - }); - - it('correctly handles execution of a slash command preview item executor', async () => { - const mockContext = { - sender: { __type: 'sender' }, - room: { __type: 'room' }, - params: { __type: 'params' }, - threadId: 'threadId', - triggerId: 'triggerId', - }; - - const mockPreviewItem = { - id: 'previewItemId', - type: 'image', - value: 'https://example.com/image.png', - }; - - const _spy = spy(mockCommandExecutorAndPreview, 'executePreviewItem'); - - const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:executePreviewItem', params: [mockPreviewItem, mockContext] }); - - await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, [mockPreviewItem, mockContext]); - - const context = _spy.calls[0].args[1]; - - assertInstanceOf(context.getRoom(), Room); - assertEquals(context.getSender(), { __type: 'sender' }); - assertEquals(context.getArguments(), { __type: 'params' }); - assertEquals(context.getThreadId(), 'threadId'); - assertEquals(context.getTriggerId(), 'triggerId'); - - assertEquals(_spy.calls[0].args[2], mockAppAccessors.getReader()); - assertEquals(_spy.calls[0].args[3], mockAppAccessors.getModifier()); - assertEquals(_spy.calls[0].args[4], mockAppAccessors.getHttp()); - assertEquals(_spy.calls[0].args[5], mockAppAccessors.getPersistence()); - - _spy.restore(); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts deleted file mode 100644 index cdc08e214f716..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import jsonrpc from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import handleUIKitInteraction, { - UIKitActionButtonInteractionContext, - UIKitBlockInteractionContext, - UIKitLivechatBlockInteractionContext, - UIKitViewCloseInteractionContext, - UIKitViewSubmitInteractionContext, -} from '../uikit/handler'; - -describe('handlers > uikit', () => { - const mockApp = { - getID: (): string => 'appId', - executeBlockActionHandler: (context: any): Promise => Promise.resolve(context), - executeViewSubmitHandler: (context: any): Promise => Promise.resolve(context), - executeViewClosedHandler: (context: any): Promise => Promise.resolve(context), - executeActionButtonHandler: (context: any): Promise => Promise.resolve(context), - executeLivechatBlockActionHandler: (context: any): Promise => Promise.resolve(context), - }; - - beforeEach(() => { - AppObjectRegistry.set('app', mockApp); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('successfully handles a call for "executeBlockActionHandler"', async () => { - const request = jsonrpc.request(1, 'app:executeBlockActionHandler', [ - { - actionId: 'actionId', - blockId: 'blockId', - value: 'value', - viewId: 'viewId', - }, - ]); - - const result = await handleUIKitInteraction(request); - assertInstanceOf(result, UIKitBlockInteractionContext); - }); - - it('successfully handles a call for "executeViewSubmitHandler"', async () => { - const request = jsonrpc.request(1, 'app:executeViewSubmitHandler', [ - { - viewId: 'viewId', - appId: 'appId', - userId: 'userId', - isAppUser: true, - values: {}, - }, - ]); - - const result = await handleUIKitInteraction(request); - assertInstanceOf(result, UIKitViewSubmitInteractionContext); - }); - - it('successfully handles a call for "executeViewClosedHandler"', async () => { - const request = jsonrpc.request(1, 'app:executeViewClosedHandler', [ - { - viewId: 'viewId', - appId: 'appId', - userId: 'userId', - isAppUser: true, - }, - ]); - - const result = await handleUIKitInteraction(request); - assertInstanceOf(result, UIKitViewCloseInteractionContext); - }); - - it('successfully handles a call for "executeActionButtonHandler"', async () => { - const request = jsonrpc.request(1, 'app:executeActionButtonHandler', [ - { - actionId: 'actionId', - appId: 'appId', - userId: 'userId', - isAppUser: true, - }, - ]); - - const result = await handleUIKitInteraction(request); - assertInstanceOf(result, UIKitActionButtonInteractionContext); - }); - - it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => { - const request = jsonrpc.request(1, 'app:executeLivechatBlockActionHandler', [ - { - actionId: 'actionId', - appId: 'appId', - userId: 'userId', - visitor: {}, - isAppUser: true, - room: {}, - }, - ]); - - const result = await handleUIKitInteraction(request); - assertInstanceOf(result, UIKitLivechatBlockInteractionContext); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts deleted file mode 100644 index b0647d30536dd..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { Buffer } from 'node:buffer'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import type { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads/IPreFileUpload'; -import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; -import { assertInstanceOf, assertNotInstanceOf, assertEquals, assertStringIncludes } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { afterEach, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertSpyCalls, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { JsonRpcError } from 'jsonrpc-lite'; - -import { createMockRequest } from './helpers/mod'; -import handleUploadEvents from '../app/handleUploadEvents'; -import { Errors } from '../lib/assertions'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; - -describe('handlers > upload', () => { - let app: App & IPreFileUpload; - let tempDir: string; - let path: string; - let file: IUploadDetails; - - beforeEach(async () => { - AppObjectRegistry.clear(); - - tempDir = await mkdtemp(join(tmpdir(), 'rc-apps-upload-')); - path = join(tempDir, 'tempfile'); - - app = { - extendConfiguration: () => {}, - executePreFileUpload: () => Promise.resolve(), - } as unknown as App; - - AppObjectRegistry.set('app', app); - - const content = 'Temp file for testing'; - - await writeFile(path, content); - - file = { - name: 'TempFile.txt', - size: content.length, - type: 'text/plain', - rid: 'RandomRoomId', - userId: 'RandomUserId', - }; - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }).catch((e) => console.warn(`Failed to remove temp dir at ${tempDir}`, e)); - }); - - it('correctly handles valid parameters', async () => { - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); - - assertNotInstanceOf(result, JsonRpcError, 'result is JsonRpcError'); - }); - - it('correctly loads the file contents for IPreFileUpload', async () => { - const _spy = spy(app as any, 'executePreFileUpload'); - - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); - - assertNotInstanceOf(result, JsonRpcError, 'result is JsonRpcError'); - assertSpyCalls(_spy, 1); - assertInstanceOf((_spy.calls[0].args[0] as any)?.content, Buffer); - }); - - it('fails when app object is not on registry', async () => { - AppObjectRegistry.clear(); - - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); - - assertInstanceOf(result, JsonRpcError); - assertEquals(result.data.code, Errors.DRT_APP_NOT_AVAILABLE); - }); - - it('fails when the app does not implement the IPreFileUpload event handler', async () => { - delete (app as any)['executePreFileUpload']; - - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); - - assertInstanceOf(result, JsonRpcError); - assertEquals(result.data.code, Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING); - }); - - it('fails when "file" is not a proper IUploadDetails object', async () => { - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file: { nope: "bad" }, path }] })); - - assertInstanceOf(result, JsonRpcError); - assertStringIncludes(result.data.err, 'Expected IUploadDetails'); - }); - - it('fails when "path" is not a proper string', async () => { - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: {} }] })); - - assertInstanceOf(result, JsonRpcError); - assertStringIncludes(result.data.err, 'Expected string'); - }); - - it('fails when "path" is not a readable file path', async () => { - await rm(path); - - const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); - - assertInstanceOf(result, JsonRpcError); - assertEquals(result.data.code, "ENOENT"); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts deleted file mode 100644 index a204b6d410b68..0000000000000 --- a/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals, assertObjectMatch, assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { JsonRpcError } from 'jsonrpc-lite'; - -import { createMockRequest } from './helpers/mod'; -import videoconfHandler from '../videoconference-handler'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; - -describe('handlers > videoconference', () => { - // deno-lint-ignore no-unused-vars - const mockMethodWithoutParam = (read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok none'); - // deno-lint-ignore no-unused-vars - const mockMethodWithOneParam = (call: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok one'); - // deno-lint-ignore no-unused-vars - const mockMethodWithTwoParam = (call: any, user: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok two'); - // deno-lint-ignore no-unused-vars - const mockMethodWithThreeParam = (call: any, user: any, options: any, read: any, modify: any, http: any, persis: any): Promise => - Promise.resolve('ok three'); - const mockProvider = { - empty: mockMethodWithoutParam, - one: mockMethodWithOneParam, - two: mockMethodWithTwoParam, - three: mockMethodWithThreeParam, - notAFunction: true, - error: () => { - throw new Error('Method execution error example'); - }, - }; - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('videoConfProvider:test-provider', mockProvider); - }); - - it('correctly handles execution of a videoconf method without additional params', async () => { - const _spy = spy(mockProvider, 'empty'); - - const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:empty', params: [] })); - - assertEquals(result, 'ok none'); - assertEquals(_spy.calls[0].args.length, 4); - - _spy.restore(); - }); - - it('correctly handles execution of a videoconf method with one param', async () => { - const _spy = spy(mockProvider, 'one'); - - const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:one', params: ['call'] })); - - assertEquals(result, 'ok one'); - assertEquals(_spy.calls[0].args.length, 5); - assertEquals(_spy.calls[0].args[0], 'call'); - - _spy.restore(); - }); - - it('correctly handles execution of a videoconf method with two params', async () => { - const _spy = spy(mockProvider, 'two'); - - const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:two', params: ['call', 'user'] })); - - assertEquals(result, 'ok two'); - assertEquals(_spy.calls[0].args.length, 6); - assertEquals(_spy.calls[0].args[0], 'call'); - assertEquals(_spy.calls[0].args[1], 'user'); - - _spy.restore(); - }); - - it('correctly handles execution of a videoconf method with three params', async () => { - const _spy = spy(mockProvider, 'three'); - - const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:three', params: ['call', 'user', 'options'] })); - - assertEquals(result, 'ok three'); - assertEquals(_spy.calls[0].args.length, 7); - assertEquals(_spy.calls[0].args[0], 'call'); - assertEquals(_spy.calls[0].args[1], 'user'); - assertEquals(_spy.calls[0].args[2], 'options'); - - _spy.restore(); - }); - - it('correctly handles an error on execution of a videoconf method', async () => { - const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:error', params: [] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: 'Method execution error example', - code: -32000, - }); - }); - - it('correctly handles an error when provider is not found', async () => { - const providerName = 'error-provider'; - const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:method`, params: [] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: `Provider ${providerName} not found`, - code: -32000, - }); - }); - - it('correctly handles an error if method is not a function of provider', async () => { - const methodName = 'notAFunction'; - const providerName = 'test-provider'; - const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:${methodName}`, params: [] })); - - assertInstanceOf(result, JsonRpcError); - assertObjectMatch(result, { - message: 'Method not found', - code: -32601, - data: { - message: `Method ${methodName} not found on provider ${providerName}`, - }, - }); - }); -}); diff --git a/packages/apps/deno-runtime/handlers/uikit/handler.ts b/packages/apps/deno-runtime/handlers/uikit/handler.ts deleted file mode 100644 index 513850ed8e475..0000000000000 --- a/packages/apps/deno-runtime/handlers/uikit/handler.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; -import { - UIKitBlockInteractionContext, - UIKitViewSubmitInteractionContext, - UIKitViewCloseInteractionContext, - UIKitActionButtonInteractionContext, -} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionContext'; -import { - IUIKitActionButtonIncomingInteraction, - IUIKitBlockIncomingInteraction, - IUIKitViewCloseIncomingInteraction, - IUIKitViewSubmitIncomingInteraction -} from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionTypes'; -import { IUIKitLivechatBlockIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatIncomingInteractionType'; -import { UIKitLivechatBlockInteractionContext } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatInteractionContext'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { AppAccessorsInstance } from '../../lib/accessors/mod'; -import { RequestContext } from '../../lib/requestContext'; -import { isOneOf } from '../lib/assertions'; -import { wrapAppForRequest } from '../../lib/wrapAppForRequest'; - -export const uikitInteractions = [ - 'executeBlockActionHandler', - 'executeViewSubmitHandler', - 'executeViewClosedHandler', - 'executeActionButtonHandler', - 'executeLivechatBlockActionHandler', -] as const; - -export { - UIKitBlockInteractionContext, - UIKitViewSubmitInteractionContext, - UIKitViewCloseInteractionContext, - UIKitActionButtonInteractionContext, - UIKitLivechatBlockInteractionContext, -}; - -export default async function handleUIKitInteraction(request: RequestContext): Promise { - const { method: reqMethod, params } = request; - const [, method] = reqMethod.split(':'); - - if (!isOneOf(method, uikitInteractions)) { - return JsonRpcError.methodNotFound(null); - } - - if (!Array.isArray(params)) { - return JsonRpcError.invalidParams(null); - } - - const app = AppObjectRegistry.get('app'); - - const interactionHandler = app?.[method as keyof App] as unknown; - - if (!app || typeof interactionHandler !== 'function') { - return JsonRpcError.methodNotFound({ - message: `App does not implement method "${method}"`, - }); - } - - const [payload] = params as [Record]; - - if (!payload) { - return JsonRpcError.invalidParams(null); - } - - let context; - - switch (method) { - case 'executeBlockActionHandler': - context = new UIKitBlockInteractionContext(payload as unknown as IUIKitBlockIncomingInteraction); - break; - case 'executeViewSubmitHandler': - context = new UIKitViewSubmitInteractionContext(payload as unknown as IUIKitViewSubmitIncomingInteraction); - break; - case 'executeViewClosedHandler': - context = new UIKitViewCloseInteractionContext(payload as unknown as IUIKitViewCloseIncomingInteraction); - break; - case 'executeActionButtonHandler': - context = new UIKitActionButtonInteractionContext(payload as unknown as IUIKitActionButtonIncomingInteraction); - break; - case 'executeLivechatBlockActionHandler': - context = new UIKitLivechatBlockInteractionContext(payload as unknown as IUIKitLivechatBlockIncomingInteraction); - break; - } - - try { - return await interactionHandler.call( - wrapAppForRequest(app, request), - context, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - AppAccessorsInstance.getModifier(), - ); - } catch (e) { - return JsonRpcError.internalError({ message: (e as { message?: string }).message }); - } -} diff --git a/packages/apps/deno-runtime/handlers/videoconference-handler.ts b/packages/apps/deno-runtime/handlers/videoconference-handler.ts deleted file mode 100644 index e1fda87000cc8..0000000000000 --- a/packages/apps/deno-runtime/handlers/videoconference-handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider'; -import { Defined, JsonRpcError } from 'jsonrpc-lite'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; -import { AppAccessorsInstance } from '../lib/accessors/mod'; -import { RequestContext } from '../lib/requestContext'; -import { wrapComposedApp } from '../lib/wrapAppForRequest'; - -export default async function videoConferenceHandler(request: RequestContext): Promise { - const { method: call, params } = request; - const { logger } = request.context; - - const [, providerName, methodName] = call.split(':'); - - const provider = AppObjectRegistry.get(`videoConfProvider:${providerName}`); - - if (!provider) { - return new JsonRpcError(`Provider ${providerName} not found`, -32000); - } - - const method = provider[methodName as keyof IVideoConfProvider]; - - if (typeof method !== 'function') { - return JsonRpcError.methodNotFound({ - message: `Method ${methodName} not found on provider ${providerName}`, - }); - } - - const [videoconf, user, options] = params as Array; - - logger.debug(`Executing ${methodName} on video conference provider...`); - - const args = [...(videoconf ? [videoconf] : []), ...(user ? [user] : []), ...(options ? [options] : [])]; - - try { - // deno-lint-ignore ban-types - const result = await (method as Function).apply(wrapComposedApp(provider, request), [ - ...args, - AppAccessorsInstance.getReader(), - AppAccessorsInstance.getModifier(), - AppAccessorsInstance.getHttp(), - AppAccessorsInstance.getPersistence(), - ]); - - logger.debug(`Video Conference Provider's ${methodName} was successfully executed.`); - - return result; - } catch (e) { - logger.debug(`Video Conference Provider's ${methodName} was unsuccessful.`); - return new JsonRpcError(e.message, -32000); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts deleted file mode 100644 index 69798dd311d7c..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BlockBuilder as AppsEngineBlockBuilder } from '@rocket.chat/apps-engine/definition/uikit/blocks/BlockBuilder'; - -import { AppObjectRegistry } from '../../../AppObjectRegistry'; - -/** - * Local BlockBuilder that extends the apps-engine BlockBuilder. - * It overrides the constructor to source the appId from the registry - * instead of requiring it as a constructor argument. - * - * @deprecated please prefer the rocket.chat/ui-kit components - */ -export class BlockBuilder extends AppsEngineBlockBuilder { - constructor() { - super(String(AppObjectRegistry.get('id') ?? '')); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts deleted file mode 100644 index f6abec1090ad4..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; - -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; - -import { RoomBuilder } from './RoomBuilder'; - -export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { - public override kind: RocketChatAssociationModel.DISCUSSION; - - private reply?: string; - - private parentMessage?: IMessage; - - constructor(data?: Partial) { - super(data); - this.kind = RocketChatAssociationModel.DISCUSSION; - this.room.type = RoomType.PRIVATE_GROUP; - } - - public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { - this.room.parentRoom = parentRoom; - return this; - } - - public getParentRoom(): IRoom { - return this.room.parentRoom!; - } - - public setReply(reply: string): IDiscussionBuilder { - this.reply = reply; - return this; - } - - public getReply(): string { - return this.reply!; - } - - public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { - this.parentMessage = parentMessage; - return this; - } - - public getParentMessage(): IMessage { - return this.parentMessage!; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts deleted file mode 100644 index 7dfb66c2dedf7..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; - -import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; -import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage'; -import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; -import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; - -import { MessageBuilder } from './MessageBuilder'; - -export interface ILivechatMessage extends EngineLivechatMessage, IMessage {} - -export class LivechatMessageBuilder implements ILivechatMessageBuilder { - public kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; - - private msg: ILivechatMessage; - - constructor(message?: ILivechatMessage) { - this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; - this.msg = message || ({} as ILivechatMessage); - } - - public setData(data: ILivechatMessage): ILivechatMessageBuilder { - delete data.id; - this.msg = data; - - return this; - } - - public setRoom(room: IRoom): ILivechatMessageBuilder { - this.msg.room = room; - return this; - } - - public getRoom(): IRoom { - return this.msg.room; - } - - public setSender(sender: IUser): ILivechatMessageBuilder { - this.msg.sender = sender; - delete this.msg.visitor; - - return this; - } - - public getSender(): IUser { - return this.msg.sender; - } - - public setText(text: string): ILivechatMessageBuilder { - this.msg.text = text; - return this; - } - - public getText(): string { - return this.msg.text!; - } - - public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { - this.msg.emoji = emoji; - return this; - } - - public getEmojiAvatar(): string { - return this.msg.emoji!; - } - - public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { - this.msg.avatarUrl = avatarUrl; - return this; - } - - public getAvatarUrl(): string { - return this.msg.avatarUrl!; - } - - public setUsernameAlias(alias: string): ILivechatMessageBuilder { - this.msg.alias = alias; - return this; - } - - public getUsernameAlias(): string { - return this.msg.alias!; - } - - public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { - if (!this.msg.attachments) { - this.msg.attachments = []; - } - - this.msg.attachments.push(attachment); - return this; - } - - public setAttachments(attachments: Array): ILivechatMessageBuilder { - this.msg.attachments = attachments; - return this; - } - - public getAttachments(): Array { - return this.msg.attachments!; - } - - public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { - if (!this.msg.attachments) { - this.msg.attachments = []; - } - - if (!this.msg.attachments[position]) { - throw new Error(`No attachment found at the index of "${position}" to replace.`); - } - - this.msg.attachments[position] = attachment; - return this; - } - - public removeAttachment(position: number): ILivechatMessageBuilder { - if (!this.msg.attachments) { - this.msg.attachments = []; - } - - if (!this.msg.attachments[position]) { - throw new Error(`No attachment found at the index of "${position}" to remove.`); - } - - this.msg.attachments.splice(position, 1); - - return this; - } - - public setEditor(user: IUser): ILivechatMessageBuilder { - this.msg.editor = user; - return this; - } - - public getEditor(): IUser { - return this.msg.editor!; - } - - public setGroupable(groupable: boolean): ILivechatMessageBuilder { - this.msg.groupable = groupable; - return this; - } - - public getGroupable(): boolean { - return this.msg.groupable!; - } - - public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { - this.msg.parseUrls = parseUrls; - return this; - } - - public getParseUrls(): boolean { - return this.msg.parseUrls!; - } - - public setToken(token: string): ILivechatMessageBuilder { - this.msg.token = token; - return this; - } - - public getToken(): string { - return this.msg.token!; - } - - public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { - this.msg.visitor = visitor; - - // @ts-expect-error - solidified behavior, we can't change - delete this.msg.sender; - - return this; - } - - public getVisitor(): IVisitor { - return this.msg.visitor!; - } - - public getMessage(): ILivechatMessage { - if (!this.msg.room) { - throw new Error('The "room" property is required.'); - } - - if (this.msg.room.type !== RoomType.LIVE_CHAT) { - throw new Error('The room is not a Livechat room'); - } - - return this.msg; - } - - public getMessageBuilder(): IMessageBuilder { - return new MessageBuilder(this.msg as IMessage); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts deleted file mode 100644 index 597e405f9b376..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { LayoutBlock } from '@rocket.chat/ui-kit'; - -import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; - -import { BlockBuilder } from './BlockBuilder'; - -export class MessageBuilder implements IMessageBuilder { - public kind: RocketChatAssociationModel.MESSAGE; - - private msg: IMessage; - - private changes: Partial = {}; - private attachmentsChanged = false; - private customFieldsChanged = false; - - constructor(message?: IMessage) { - this.kind = RocketChatAssociationModel.MESSAGE; - this.msg = message || ({} as IMessage); - } - - public setData(data: IMessage): IMessageBuilder { - delete data.id; - this.msg = data; - - return this as IMessageBuilder; - } - - public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { - this.msg = data; - this.msg.editor = editor; - this.msg.editedAt = new Date(); - - this.changes = structuredClone(this.msg); - - return this as IMessageBuilder; - } - - public setThreadId(threadId: string): IMessageBuilder { - this.msg.threadId = threadId; - this.changes.threadId = threadId; - - return this as IMessageBuilder; - } - - public getThreadId(): string { - return this.msg.threadId!; - } - - public setRoom(room: IRoom): IMessageBuilder { - this.msg.room = room; - this.changes.room = room; - - return this as IMessageBuilder; - } - - public getRoom(): IRoom { - return this.msg.room; - } - - public setSender(sender: IUser): IMessageBuilder { - this.msg.sender = sender; - this.changes.sender = sender; - - return this as IMessageBuilder; - } - - public getSender(): IUser { - return this.msg.sender; - } - - public setText(text: string): IMessageBuilder { - this.msg.text = text; - this.changes.text = text; - - return this as IMessageBuilder; - } - - public getText(): string { - return this.msg.text!; - } - - public setEmojiAvatar(emoji: string): IMessageBuilder { - this.msg.emoji = emoji; - this.changes.emoji = emoji; - - return this as IMessageBuilder; - } - - public getEmojiAvatar(): string { - return this.msg.emoji!; - } - - public setAvatarUrl(avatarUrl: string): IMessageBuilder { - this.msg.avatarUrl = avatarUrl; - this.changes.avatarUrl = avatarUrl; - - return this as IMessageBuilder; - } - - public getAvatarUrl(): string { - return this.msg.avatarUrl!; - } - - public setUsernameAlias(alias: string): IMessageBuilder { - this.msg.alias = alias; - this.changes.alias = alias; - - return this as IMessageBuilder; - } - - public getUsernameAlias(): string { - return this.msg.alias!; - } - - public addAttachment(attachment: IMessageAttachment): IMessageBuilder { - if (!this.msg.attachments) { - this.msg.attachments = []; - } - - this.msg.attachments.push(attachment); - this.attachmentsChanged = true; - - return this as IMessageBuilder; - } - - public setAttachments(attachments: Array): IMessageBuilder { - this.msg.attachments = attachments; - this.attachmentsChanged = true; - - return this as IMessageBuilder; - } - - public getAttachments(): Array { - return this.msg.attachments!; - } - - public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { - if (!this.msg.attachments?.[position]) { - throw new Error(`No attachment found at the index of "${position}" to replace.`); - } - - this.msg.attachments[position] = attachment; - this.attachmentsChanged = true; - - return this as IMessageBuilder; - } - - public removeAttachment(position: number): IMessageBuilder { - if (!this.msg.attachments?.[position]) { - throw new Error(`No attachment found at the index of "${position}" to remove.`); - } - - this.msg.attachments.splice(position, 1); - this.attachmentsChanged = true; - - return this as IMessageBuilder; - } - - public setEditor(user: IUser): IMessageBuilder { - this.msg.editor = user; - this.changes.editor = user; - - return this as IMessageBuilder; - } - - public getEditor(): IUser { - return this.msg.editor!; - } - - public setGroupable(groupable: boolean): IMessageBuilder { - this.msg.groupable = groupable; - this.changes.groupable = groupable; - - return this as IMessageBuilder; - } - - public getGroupable(): boolean { - return this.msg.groupable!; - } - - public setParseUrls(parseUrls: boolean): IMessageBuilder { - this.msg.parseUrls = parseUrls; - this.changes.parseUrls = parseUrls; - - return this as IMessageBuilder; - } - - public getParseUrls(): boolean { - return this.msg.parseUrls!; - } - - public getMessage(): IMessage { - if (!this.msg.room) { - throw new Error('The "room" property is required.'); - } - - return this.msg; - } - - public addBlocks(blocks: BlockBuilder | Array) { - if (!Array.isArray(this.msg.blocks)) { - this.msg.blocks = []; - } - - if (blocks instanceof BlockBuilder) { - this.msg.blocks.push(...blocks.getBlocks()); - } else { - this.msg.blocks.push(...blocks); - } - - return this as IMessageBuilder; - } - - public setBlocks(blocks: BlockBuilder | Array) { - const blockArray: Array = blocks instanceof BlockBuilder ? blocks.getBlocks() : blocks; - - this.msg.blocks = blockArray; - this.changes.blocks = blockArray; - - return this as IMessageBuilder; - } - - public getBlocks() { - return this.msg.blocks!; - } - - public addCustomField(key: string, value: unknown): IMessageBuilder { - if (!this.msg.customFields) { - this.msg.customFields = {}; - } - - if (this.msg.customFields[key]) { - throw new Error(`The message already contains a custom field by the key: ${key}`); - } - - if (key.includes('.')) { - throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); - } - - this.msg.customFields[key] = value; - - this.customFieldsChanged = true; - - return this as IMessageBuilder; - } - - public getChanges(): Partial { - const changes: typeof this.changes = structuredClone(this.changes); - - if (this.attachmentsChanged) { - changes.attachments = structuredClone(this.msg.attachments); - } - - if (this.customFieldsChanged) { - changes.customFields = structuredClone(this.msg.customFields); - } - - return changes; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts deleted file mode 100644 index c972330650df3..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; - -import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -export class RoomBuilder implements IRoomBuilder { - public kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; - - protected room: IRoom; - - private members: Array; - - private changes: Partial = {}; - private customFieldsChanged = false; - - constructor(data?: Partial) { - this.kind = RocketChatAssociationModel.ROOM; - this.room = (data || { customFields: {} }) as IRoom; - this.members = []; - } - - public setData(data: Partial): IRoomBuilder { - delete data.id; - this.room = data as IRoom; - - this.changes = structuredClone(this.room); - - return this; - } - - public setDisplayName(name: string): IRoomBuilder { - this.room.displayName = name; - this.changes.displayName = name; - - return this; - } - - public getDisplayName(): string { - return this.room.displayName!; - } - - public setSlugifiedName(name: string): IRoomBuilder { - this.room.slugifiedName = name; - this.changes.slugifiedName = name; - - return this; - } - - public getSlugifiedName(): string { - return this.room.slugifiedName; - } - - public setType(type: RoomType): IRoomBuilder { - this.room.type = type; - this.changes.type = type; - - return this; - } - - public getType(): RoomType { - return this.room.type; - } - - public setCreator(creator: IUser): IRoomBuilder { - this.room.creator = creator; - this.changes.creator = creator; - - return this; - } - - public getCreator(): IUser { - return this.room.creator; - } - - /** - * @deprecated - */ - public addUsername(username: string): IRoomBuilder { - this.addMemberToBeAddedByUsername(username); - return this; - } - - /** - * @deprecated - */ - public setUsernames(usernames: Array): IRoomBuilder { - this.setMembersToBeAddedByUsernames(usernames); - return this; - } - - /** - * @deprecated - */ - public getUsernames(): Array { - const usernames = this.getMembersToBeAddedUsernames(); - if (usernames && usernames.length > 0) { - return usernames; - } - return this.room.usernames || []; - } - - public addMemberToBeAddedByUsername(username: string): IRoomBuilder { - this.members.push(username); - return this; - } - - public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { - this.members = usernames; - return this; - } - - public getMembersToBeAddedUsernames(): Array { - return this.members; - } - - public setDefault(isDefault: boolean): IRoomBuilder { - this.room.isDefault = isDefault; - this.changes.isDefault = isDefault; - - return this; - } - - public getIsDefault(): boolean { - return this.room.isDefault!; - } - - public setReadOnly(isReadOnly: boolean): IRoomBuilder { - this.room.isReadOnly = isReadOnly; - this.changes.isReadOnly = isReadOnly; - - return this; - } - - public getIsReadOnly(): boolean { - return this.room.isReadOnly!; - } - - public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { - this.room.displaySystemMessages = displaySystemMessages; - this.changes.displaySystemMessages = displaySystemMessages; - - return this; - } - - public getDisplayingOfSystemMessages(): boolean { - return this.room.displaySystemMessages!; - } - - public addCustomField(key: string, value: object): IRoomBuilder { - if (typeof this.room.customFields !== 'object') { - this.room.customFields = {}; - } - - this.room.customFields[key] = value; - - this.customFieldsChanged = true; - - return this; - } - - public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { - this.room.customFields = fields; - this.customFieldsChanged = true; - - return this; - } - - public getCustomFields(): { [key: string]: object } { - return this.room.customFields!; - } - - public getUserIds(): Array { - return this.room.userIds!; - } - - public getRoom(): IRoom { - return this.room; - } - - public getChanges() { - const changes: Partial = structuredClone(this.changes); - - if (this.customFieldsChanged) { - changes.customFields = structuredClone(this.room.customFields); - } - - return changes; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts deleted file mode 100644 index 4efb8a99f0e74..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; -import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings'; -import type { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -export class UserBuilder implements IUserBuilder { - public kind: RocketChatAssociationModel.USER; - - private user: Partial; - - constructor(user?: Partial) { - this.kind = RocketChatAssociationModel.USER; - this.user = user || ({} as Partial); - } - - public setData(data: Partial): IUserBuilder { - delete data.id; - this.user = data; - - return this; - } - - public setEmails(emails: Array): IUserBuilder { - this.user.emails = emails; - return this; - } - - public getEmails(): Array { - return this.user.emails!; - } - - public setDisplayName(name: string): IUserBuilder { - this.user.name = name; - return this; - } - - public getDisplayName(): string { - return this.user.name!; - } - - public setUsername(username: string): IUserBuilder { - this.user.username = username; - return this; - } - - public getUsername(): string { - return this.user.username!; - } - - public setRoles(roles: Array): IUserBuilder { - this.user.roles = roles; - return this; - } - - public getRoles(): Array { - return this.user.roles!; - } - - public getSettings(): Partial { - return this.user.settings!; - } - - public getUser(): Partial { - if (!this.user.username) { - throw new Error('The "username" property is required.'); - } - - if (!this.user.name) { - throw new Error('The "name" property is required.'); - } - - return this.user; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts deleted file mode 100644 index 77bfafda84d12..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder'; -import type { IGroupVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; - -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -export type AppVideoConference = Pick & { - createdBy: IGroupVideoConference['createdBy']['_id']; -}; - -export class VideoConferenceBuilder implements IVideoConferenceBuilder { - public kind: RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; - - protected call: AppVideoConference; - - constructor(data?: Partial) { - this.call = (data || {}) as AppVideoConference; - } - - public setData(data: Partial): IVideoConferenceBuilder { - this.call = { - rid: data.rid!, - createdBy: data.createdBy!, - providerName: data.providerName!, - title: data.title!, - discussionRid: data.discussionRid, - }; - - return this; - } - - public setRoomId(rid: string): IVideoConferenceBuilder { - this.call.rid = rid; - return this; - } - - public getRoomId(): string { - return this.call.rid; - } - - public setCreatedBy(userId: string): IVideoConferenceBuilder { - this.call.createdBy = userId; - return this; - } - - public getCreatedBy(): string { - return this.call.createdBy; - } - - public setProviderName(userId: string): IVideoConferenceBuilder { - this.call.providerName = userId; - return this; - } - - public getProviderName(): string { - return this.call.providerName; - } - - public setProviderData(data: Record | undefined): IVideoConferenceBuilder { - this.call.providerData = data; - return this; - } - - public getProviderData(): Record { - return this.call.providerData!; - } - - public setTitle(userId: string): IVideoConferenceBuilder { - this.call.title = userId; - return this; - } - - public getTitle(): string { - return this.call.title; - } - - public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder { - this.call.discussionRid = rid; - return this; - } - - public getDiscussionRid(): AppVideoConference['discussionRid'] { - return this.call.discussionRid; - } - - public getVideoConference(): AppVideoConference { - return this.call; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts deleted file mode 100644 index 03a39ff1fedc2..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { IHttpExtend, IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; - -export class HttpExtend implements IHttpExtend { - private headers: Map; - - private params: Map; - - private requests: Array; - - private responses: Array; - - constructor() { - this.headers = new Map(); - this.params = new Map(); - this.requests = []; - this.responses = []; - } - - public provideDefaultHeader(key: string, value: string): void { - this.headers.set(key, value); - } - - public provideDefaultHeaders(headers: { [key: string]: string }): void { - Object.keys(headers).forEach((key) => this.headers.set(key, headers[key])); - } - - public provideDefaultParam(key: string, value: string): void { - this.params.set(key, value); - } - - public provideDefaultParams(params: { [key: string]: string }): void { - Object.keys(params).forEach((key) => this.params.set(key, params[key])); - } - - public providePreRequestHandler(handler: IHttpPreRequestHandler): void { - this.requests.push(handler); - } - - public providePreResponseHandler(handler: IHttpPreResponseHandler): void { - this.responses.push(handler); - } - - public getDefaultHeaders(): Map { - return new Map(this.headers); - } - - public getDefaultParams(): Map { - return new Map(this.params); - } - - public getPreRequestHandlers(): Array { - return Array.from(this.requests); - } - - public getPreResponseHandlers(): Array { - return Array.from(this.responses); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts deleted file mode 100644 index ca251bc541dcf..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment'; - -export class MessageExtender implements IMessageExtender { - public readonly kind: RocketChatAssociationModel.MESSAGE; - - constructor(private msg: IMessage) { - this.kind = RocketChatAssociationModel.MESSAGE; - - if (!Array.isArray(msg.attachments)) { - this.msg.attachments = []; - } - } - - public addCustomField(key: string, value: unknown): IMessageExtender { - if (!this.msg.customFields) { - this.msg.customFields = {}; - } - - if (this.msg.customFields[key]) { - throw new Error(`The message already contains a custom field by the key: ${key}`); - } - - if (key.includes('.')) { - throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); - } - - this.msg.customFields[key] = value; - - return this; - } - - public addAttachment(attachment: IMessageAttachment): IMessageExtender { - this.ensureAttachment(); - - this.msg.attachments!.push(attachment); - - return this; - } - - public addAttachments(attachments: Array): IMessageExtender { - this.ensureAttachment(); - - this.msg.attachments = this.msg.attachments!.concat(attachments); - - return this; - } - - public getMessage(): IMessage { - return structuredClone(this.msg); - } - - private ensureAttachment(): void { - if (!Array.isArray(this.msg.attachments)) { - this.msg.attachments = []; - } - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts deleted file mode 100644 index 3a22c9277c626..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; - -export class RoomExtender implements IRoomExtender { - public kind: RocketChatAssociationModel.ROOM; - - private members: Array; - - constructor(private room: IRoom) { - this.kind = RocketChatAssociationModel.ROOM; - this.members = []; - } - - public addCustomField(key: string, value: unknown): IRoomExtender { - if (!this.room.customFields) { - this.room.customFields = {}; - } - - if (this.room.customFields[key]) { - throw new Error(`The room already contains a custom field by the key: ${key}`); - } - - if (key.includes('.')) { - throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); - } - - this.room.customFields[key] = value; - - return this; - } - - public addMember(user: IUser): IRoomExtender { - if (this.members.find((u) => u.username === user.username)) { - throw new Error('The user is already in the room.'); - } - - this.members.push(user); - - return this; - } - - public getMembersBeingAdded(): Array { - return this.members; - } - - public getUsernamesOfMembersBeingAdded(): Array { - return this.members.map((u) => u.username); - } - - public getRoom(): IRoom { - return structuredClone(this.room); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts b/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts deleted file mode 100644 index bd28fd12c179f..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend'; -import type { VideoConference, VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; -import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -export class VideoConferenceExtender implements IVideoConferenceExtender { - public kind: RocketChatAssociationModel.VIDEO_CONFERENCE; - - constructor(private videoConference: VideoConference) { - this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE; - } - - public setProviderData(value: Record): IVideoConferenceExtender { - this.videoConference.providerData = value; - - return this; - } - - public setStatus(value: VideoConference['status']): IVideoConferenceExtender { - this.videoConference.status = value; - - return this; - } - - public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender { - this.videoConference.endedBy = { - _id: value, - // Name and username will be loaded automatically by the bridge - username: '', - name: '', - }; - - return this; - } - - public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender { - this.videoConference.endedAt = value; - - return this; - } - - public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender { - this.videoConference.users.push({ - _id: userId, - // @ts-expect-error - Original typing is frail - ts, - // Name and username will be loaded automatically by the bridge - username: '', - name: '', - }); - - return this; - } - - public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender { - this.videoConference.discussionRid = rid; - - return this; - } - - public getVideoConference(): VideoConference { - return structuredClone(this.videoConference); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts b/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts deleted file mode 100644 index 6840c3ab5baa3..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ErrorObject } from 'jsonrpc-lite'; - -// deno-lint-ignore no-explicit-any -- that is the type we get from `catch` -export const formatErrorResponse = (error: any): Error => { - if (error instanceof ErrorObject || typeof error?.error?.message === 'string') { - return new Error(error.error.message); - } - - if (error instanceof Error) { - return error; - } - - return new Error('An unknown error occurred', { cause: error }); -}; diff --git a/packages/apps/deno-runtime/lib/accessors/http.ts b/packages/apps/deno-runtime/lib/accessors/http.ts deleted file mode 100644 index 78e4e8d7f8d37..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/http.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { IHttp, IHttpExtend, IHttpRequest, IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; -import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence'; -import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead'; - -import * as Messenger from '../messenger'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { formatErrorResponse } from './formatResponseErrorHandler'; - -type RequestMethod = 'get' | 'post' | 'put' | 'head' | 'delete' | 'patch'; - -export class Http implements IHttp { - private httpExtender: IHttpExtend; - private read: IRead; - private persistence: IPersistence; - private senderFn: typeof Messenger.sendRequest; - - constructor(read: IRead, persistence: IPersistence, httpExtender: IHttpExtend, senderFn: typeof Messenger.sendRequest) { - this.read = read; - this.persistence = persistence; - this.httpExtender = httpExtender; - this.senderFn = senderFn; - // this.httpExtender = new HttpExtend(); - } - - public get(url: string, options?: IHttpRequest): Promise { - return this._processHandler(url, 'get', options); - } - - public put(url: string, options?: IHttpRequest): Promise { - return this._processHandler(url, 'put', options); - } - - public post(url: string, options?: IHttpRequest): Promise { - return this._processHandler(url, 'post', options); - } - - public del(url: string, options?: IHttpRequest): Promise { - return this._processHandler(url, 'delete', options); - } - - public patch(url: string, options?: IHttpRequest): Promise { - return this._processHandler(url, 'patch', options); - } - - private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise { - let request = options || {}; - - if (typeof request.headers === 'undefined') { - request.headers = {}; - } - - this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => { - if (typeof request.headers?.[key] !== 'string') { - request.headers![key] = value; - } - }); - - if (typeof request.params === 'undefined') { - request.params = {}; - } - - this.httpExtender.getDefaultParams().forEach((value: string, key: string) => { - if (typeof request.params?.[key] !== 'string') { - request.params![key] = value; - } - }); - - for (const handler of this.httpExtender.getPreRequestHandlers()) { - request = await handler.executePreHttpRequest(url, request, this.read, this.persistence); - } - - let { result: response } = await this.senderFn({ - method: `bridges:getHttpBridge:doCall`, - params: [ - { - appId: AppObjectRegistry.get('id'), - method, - url, - request, - }, - ], - }).catch((error) => { - throw formatErrorResponse(error); - }); - - for (const handler of this.httpExtender.getPreResponseHandlers()) { - response = await handler.executePreHttpResponse(response as IHttpResponse, this.read, this.persistence); - } - - return response as IHttpResponse; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/mod.ts b/packages/apps/deno-runtime/lib/accessors/mod.ts deleted file mode 100644 index c0f13d725b7d8..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/mod.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { IApiExtend } from '@rocket.chat/apps-engine/definition/accessors/IApiExtend'; -import type { IAppAccessors } from '@rocket.chat/apps-engine/definition/accessors/IAppAccessors'; -import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationExtend'; -import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify'; -import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead'; -import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite'; -import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp'; -import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify'; -import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; -import type { IOutboundCommunicationProviderExtend } from '@rocket.chat/apps-engine/definition/accessors/IOutboundCommunicationProviderExtend'; -import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence'; -import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead'; -import type { ISchedulerExtend } from '@rocket.chat/apps-engine/definition/accessors/ISchedulerExtend'; -import type { ISlashCommandsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsExtend'; -import type { ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsModify'; -import type { IVideoConfProvidersExtend } from '@rocket.chat/apps-engine/definition/accessors/IVideoConfProvidersExtend'; -import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi'; -import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata'; -import type { - IOutboundEmailMessageProvider, - IOutboundPhoneMessageProvider, -} from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider'; -import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor'; -import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand'; -import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider'; - -import { HttpExtend } from './extenders/HttpExtender'; -import { formatErrorResponse } from './formatResponseErrorHandler'; -import { Http } from './http'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import * as Messenger from '../messenger'; -import { ModifyCreator } from './modify/ModifyCreator'; -import { ModifyExtender } from './modify/ModifyExtender'; -import { ModifyUpdater } from './modify/ModifyUpdater'; -import { Notifier } from './notifier'; - -/** Helper: extends T with an internal _proxy property used for delegation. */ -type WithProxy = T & { _proxy: T }; - -const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; - -// We need to create this object first thing, as we'll handle references to it later on -if (!AppObjectRegistry.has('apiEndpoints')) { - AppObjectRegistry.set('apiEndpoints', []); -} - -export class AppAccessors { - private defaultAppAccessors?: IAppAccessors; - - private environmentRead?: IEnvironmentRead; - - private environmentWriter?: IEnvironmentWrite; - - private configModifier?: IConfigurationModify; - - private configExtender?: IConfigurationExtend; - - private reader?: IRead; - - private modifier?: IModify; - - private persistence?: IPersistence; - - private creator?: ModifyCreator; - - private updater?: ModifyUpdater; - - private extender?: ModifyExtender; - - private httpExtend: IHttpExtend = new HttpExtend(); - - private http?: IHttp; - - private notifier?: INotifier; - - private proxify: (namespace: string, overrides?: Record unknown>) => T; - - constructor(private readonly senderFn: typeof Messenger.sendRequest) { - this.proxify = (namespace: string, overrides: Record unknown> = {}): T => - new Proxy( - { __kind: `accessor:${namespace}` }, - { - get: (_target: unknown, prop: string) => (...params: unknown[]) => { - // We don't want to send a request for this prop - if (prop === 'toJSON') { - return {}; - } - - // If the prop is inteded to be overriden by the caller - if (prop in overrides) { - return overrides[prop].apply(undefined, params); - } - - return senderFn({ - method: `accessor:${namespace}:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }); - }, - }, - ) as T; - - this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn()); - this.notifier = new Notifier(this.getSenderFn()); - } - - public getSenderFn() { - return this.senderFn; - } - - public getEnvironmentRead(): IEnvironmentRead { - if (!this.environmentRead) { - this.environmentRead = { - getSettings: () => this.proxify('getEnvironmentRead:getSettings'), - getServerSettings: () => this.proxify('getEnvironmentRead:getServerSettings'), - getEnvironmentVariables: () => this.proxify('getEnvironmentRead:getEnvironmentVariables'), - }; - } - - return this.environmentRead; - } - - public getEnvironmentWrite() { - if (!this.environmentWriter) { - this.environmentWriter = { - getSettings: () => this.proxify('getEnvironmentWrite:getSettings'), - getServerSettings: () => this.proxify('getEnvironmentWrite:getServerSettings'), - }; - } - - return this.environmentWriter; - } - - public getConfigurationModify() { - if (!this.configModifier) { - const slashCommandsModify: WithProxy = { - _proxy: this.proxify('getConfigurationModify:slashCommands'), - modifySlashCommand(slashcommand: ISlashCommand) { - // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand - AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); - - return this._proxy.modifySlashCommand(slashcommand); - }, - disableSlashCommand(command: string) { - return this._proxy.disableSlashCommand(command); - }, - enableSlashCommand(command: string) { - return this._proxy.enableSlashCommand(command); - }, - }; - - this.configModifier = { - scheduler: this.proxify('getConfigurationModify:scheduler'), - slashCommands: slashCommandsModify, - serverSettings: this.proxify('getConfigurationModify:serverSettings'), - }; - } - - return this.configModifier; - } - - public getConfigurationExtend() { - if (!this.configExtender) { - const { senderFn } = this; - - const apiExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:api'), - async provideApi(api: IApi) { - const apiEndpoints = AppObjectRegistry.get('apiEndpoints')!; - - api.endpoints.forEach((endpoint) => { - endpoint._availableMethods = httpMethods.filter((method) => typeof endpoint[method] === 'function'); - - // We need to keep a reference to the endpoint around for us to call the executor later - AppObjectRegistry.set(`api:${endpoint.path}`, endpoint); - }); - - const result = await this._proxy.provideApi(api); - - // Let's call the listApis method to cache the info from the endpoints - // Also, since this is a side-effect, we do it async so we can return to the caller - senderFn({ method: 'accessor:api:listApis' }) - .then((response) => apiEndpoints.push(...(response.result as IApiEndpointMetadata[]))) - .catch((err) => err.error); - - return result; - }, - }; - - const schedulerExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:scheduler'), - registerProcessors(processors: IProcessor[]) { - // Store the processor instance to use when the Apps-Engine calls the processor - processors.forEach((processor) => { - AppObjectRegistry.set(`scheduler:${processor.id}`, processor); - }); - - return this._proxy.registerProcessors(processors); - }, - }; - - const videoConfProviders: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:videoConfProviders'), - provideVideoConfProvider(provider: IVideoConfProvider) { - // Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider - AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider); - - return this._proxy.provideVideoConfProvider(provider); - }, - }; - - const outboundCommunication: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:outboundCommunication'), - registerEmailProvider(provider: IOutboundEmailMessageProvider) { - AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); - return this._proxy.registerEmailProvider(provider); - }, - registerPhoneProvider(provider: IOutboundPhoneMessageProvider) { - AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); - return this._proxy.registerPhoneProvider(provider); - }, - }; - - const slashCommandsExtend: WithProxy = { - _proxy: this.proxify('getConfigurationExtend:slashCommands'), - provideSlashCommand(slashcommand: ISlashCommand) { - // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand - AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); - - return this._proxy.provideSlashCommand(slashcommand); - }, - }; - - this.configExtender = { - ui: this.proxify('getConfigurationExtend:ui'), - http: this.httpExtend, - settings: this.proxify('getConfigurationExtend:settings'), - externalComponents: this.proxify('getConfigurationExtend:externalComponents'), - api: apiExtend, - scheduler: schedulerExtend, - videoConfProviders, - outboundCommunication, - slashCommands: slashCommandsExtend, - }; - } - - return this.configExtender; - } - - public getDefaultAppAccessors() { - if (!this.defaultAppAccessors) { - this.defaultAppAccessors = { - environmentReader: this.getEnvironmentRead(), - environmentWriter: this.getEnvironmentWrite(), - reader: this.getReader(), - http: this.getHttp(), - providedApiEndpoints: AppObjectRegistry.get('apiEndpoints') as IApiEndpointMetadata[], - }; - } - - return this.defaultAppAccessors; - } - - public getReader() { - if (!this.reader) { - this.reader = { - getEnvironmentReader: () => ({ - getSettings: () => this.proxify('getReader:getEnvironmentReader:getSettings'), - getServerSettings: () => this.proxify('getReader:getEnvironmentReader:getServerSettings'), - getEnvironmentVariables: () => this.proxify('getReader:getEnvironmentReader:getEnvironmentVariables'), - }), - getMessageReader: () => this.proxify('getReader:getMessageReader'), - getPersistenceReader: () => this.proxify('getReader:getPersistenceReader'), - getRoomReader: () => this.proxify('getReader:getRoomReader'), - getUserReader: () => this.proxify('getReader:getUserReader'), - getNotifier: () => this.getNotifier(), - getLivechatReader: () => this.proxify('getReader:getLivechatReader'), - getUploadReader: () => this.proxify('getReader:getUploadReader'), - getCloudWorkspaceReader: () => this.proxify('getReader:getCloudWorkspaceReader'), - getVideoConferenceReader: () => this.proxify('getReader:getVideoConferenceReader'), - getOAuthAppsReader: () => this.proxify('getReader:getOAuthAppsReader'), - getThreadReader: () => this.proxify('getReader:getThreadReader'), - getRoleReader: () => this.proxify('getReader:getRoleReader'), - getContactReader: () => this.proxify('getReader:getContactReader'), - getExperimentalReader: () => this.proxify('getReader:getExperimentalReader'), - }; - } - - return this.reader; - } - - public getModifier() { - if (!this.modifier) { - this.modifier = { - getCreator: this.getCreator.bind(this), - getUpdater: this.getUpdater.bind(this), - getExtender: this.getExtender.bind(this), - getDeleter: () => this.proxify('getModifier:getDeleter'), - getNotifier: () => this.getNotifier(), - getUiController: () => this.proxify('getModifier:getUiController'), - getScheduler: () => this.proxify('getModifier:getScheduler'), - getOAuthAppsModifier: () => this.proxify('getModifier:getOAuthAppsModifier'), - getModerationModifier: () => this.proxify('getModifier:getModerationModifier'), - }; - } - - return this.modifier; - } - - public getPersistence() { - if (!this.persistence) { - this.persistence = this.proxify('getPersistence'); - } - - return this.persistence; - } - - public getHttp() { - return this.http; - } - - private getCreator() { - if (!this.creator) { - this.creator = new ModifyCreator(this.senderFn); - } - - return this.creator; - } - - private getUpdater() { - if (!this.updater) { - this.updater = new ModifyUpdater(this.senderFn); - } - - return this.updater; - } - - private getExtender() { - if (!this.extender) { - this.extender = new ModifyExtender(this.senderFn); - } - - return this.extender; - } - - private getNotifier() { - return this.notifier; - } -} - -export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest); diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts deleted file mode 100644 index 7c0f7cb01402b..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import { UIHelper } from '@rocket.chat/apps/dist/server/misc/UIHelper'; -import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator'; -import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator'; -import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; -import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; -import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser'; -import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; -import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; -import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; -import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder'; -import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder'; -import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; -import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder'; -import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; - -import * as Messenger from '../../messenger'; - -import { BlockBuilder } from '../builders/BlockBuilder'; -import { MessageBuilder } from '../builders/MessageBuilder'; -import { DiscussionBuilder } from '../builders/DiscussionBuilder'; -import { ILivechatMessage, LivechatMessageBuilder } from '../builders/LivechatMessageBuilder'; -import { RoomBuilder } from '../builders/RoomBuilder'; -import { UserBuilder } from '../builders/UserBuilder'; -import { AppVideoConference, VideoConferenceBuilder } from '../builders/VideoConferenceBuilder'; -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { formatErrorResponse } from '../formatResponseErrorHandler'; - -export class ModifyCreator implements IModifyCreator { - constructor(private readonly senderFn: typeof Messenger.sendRequest) {} - - getLivechatCreator(): ILivechatCreator { - return new Proxy( - { __kind: 'getLivechatCreator' }, - { - get: (_target: unknown, prop: string) => { - // It's not worthwhile to make an asynchronous request for such a simple method - if (prop === 'createToken') { - return () => randomBytes(16).toString('hex'); - } - - if (prop === 'toJSON') { - return () => ({}); - } - - return (...params: unknown[]) => - this.senderFn({ - method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }); - }, - }, - ) as ILivechatCreator; - } - - getUploadCreator(): IUploadCreator { - return new Proxy( - { __kind: 'getUploadCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IUploadCreator; - } - - getEmailCreator(): IEmailCreator { - return new Proxy( - { __kind: 'getEmailCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getEmailCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IEmailCreator; - } - - getContactCreator(): IContactCreator { - return new Proxy( - { __kind: 'getContactCreator' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getCreator:getContactCreator:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IContactCreator; - } - - getBlockBuilder() { - return new BlockBuilder(); - } - - startMessage(data?: IMessage) { - if (data) { - delete data.id; - } - - return new MessageBuilder(data); - } - - startLivechatMessage(data?: ILivechatMessage) { - if (data) { - delete data.id; - } - - return new LivechatMessageBuilder(data); - } - - startRoom(data?: IRoom) { - if (data) { - // @ts-ignore - this has been imported from the Apps-Engine - delete data.id; - } - - return new RoomBuilder(data); - } - - startDiscussion(data?: Partial) { - if (data) { - delete data.id; - } - - return new DiscussionBuilder(data); - } - - startVideoConference(data?: Partial) { - return new VideoConferenceBuilder(data); - } - - startBotUser(data?: Partial) { - if (data) { - delete data.id; - - const { roles } = data; - - if (roles?.length) { - const hasRole = roles - .map((role: string) => role.toLocaleLowerCase()) - .some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator'); - - if (hasRole) { - throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); - } - } - - if (!data.type) { - data.type = UserType.BOT; - } - } - - return new UserBuilder(data); - } - - public finish( - builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, - ): Promise { - switch (builder.kind) { - case RocketChatAssociationModel.MESSAGE: - return this._finishMessage(builder as IMessageBuilder); - case RocketChatAssociationModel.LIVECHAT_MESSAGE: - return this._finishLivechatMessage(builder as ILivechatMessageBuilder); - case RocketChatAssociationModel.ROOM: - return this._finishRoom(builder as IRoomBuilder); - case RocketChatAssociationModel.DISCUSSION: - return this._finishDiscussion(builder as DiscussionBuilder); - case RocketChatAssociationModel.VIDEO_CONFERENCE: - return this._finishVideoConference(builder as IVideoConferenceBuilder); - case RocketChatAssociationModel.USER: - return this._finishUser(builder as IUserBuilder); - default: - throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); - } - } - - private async _finishMessage(builder: IMessageBuilder): Promise { - const result = builder.getMessage(); - delete result.id; - - if (!result.sender || !result.sender.id) { - const response = await this.senderFn({ - method: 'bridges:getUserBridge:doGetAppUser', - params: ['APP_ID'], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - const appUser = response.result; - - if (!appUser) { - throw new Error('Invalid sender assigned to the message.'); - } - - result.sender = appUser as IUser; - } - - if (result.blocks?.length) { - // Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where? - result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); - } - - const response = await this.senderFn({ - method: 'bridges:getMessageBridge:doCreate', - params: [result, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } - - private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { - if (builder.getSender() && !builder.getVisitor()) { - return this._finishMessage(builder.getMessageBuilder()); - } - - const result = builder.getMessage(); - delete result.id; - - if (!result.token && (!result.visitor || !result.visitor.token)) { - throw new Error('Invalid visitor sending the message'); - } - - result.token = result.visitor ? result.visitor.token : result.token; - - const response = await this.senderFn({ - method: 'bridges:getLivechatBridge:doCreateMessage', - params: [result, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } - - private async _finishRoom(builder: IRoomBuilder): Promise { - const result = builder.getRoom(); - // @ts-expect-error - can't conciliate - delete result.id; - - if (!result.type) { - throw new Error('Invalid type assigned to the room.'); - } - - if (result.type !== RoomType.LIVE_CHAT) { - if (!result.creator || !result.creator.id) { - throw new Error('Invalid creator assigned to the room.'); - } - } - - if (result.type !== RoomType.DIRECT_MESSAGE) { - if (result.type !== RoomType.LIVE_CHAT) { - if (!result.slugifiedName || !result.slugifiedName.trim()) { - throw new Error('Invalid slugifiedName assigned to the room.'); - } - } - - if (!result.displayName || !result.displayName.trim()) { - throw new Error('Invalid displayName assigned to the room.'); - } - } - - const response = await this.senderFn({ - method: 'bridges:getRoomBridge:doCreate', - params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } - - private async _finishDiscussion(builder: DiscussionBuilder): Promise { - const room = builder.getRoom(); - - // @ts-expect-error - can't conciliate - delete room.id; - - if (!room.creator || !room.creator.id) { - throw new Error('Invalid creator assigned to the discussion.'); - } - - if (!room.slugifiedName || !room.slugifiedName.trim()) { - throw new Error('Invalid slugifiedName assigned to the discussion.'); - } - - if (!room.displayName || !room.displayName.trim()) { - throw new Error('Invalid displayName assigned to the discussion.'); - } - - if (!room.parentRoom || !room.parentRoom.id) { - throw new Error('Invalid parentRoom assigned to the discussion.'); - } - - const response = await this.senderFn({ - method: 'bridges:getRoomBridge:doCreateDiscussion', - params: [room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } - - private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise { - const videoConference = builder.getVideoConference(); - - if (!videoConference.createdBy) { - throw new Error('Invalid creator assigned to the video conference.'); - } - - if (!videoConference.providerName?.trim()) { - throw new Error('Invalid provider name assigned to the video conference.'); - } - - if (!videoConference.rid) { - throw new Error('Invalid roomId assigned to the video conference.'); - } - - const response = await this.senderFn({ - method: 'bridges:getVideoConferenceBridge:doCreate', - params: [videoConference, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } - - private async _finishUser(builder: IUserBuilder): Promise { - const user = builder.getUser(); - - const response = await this.senderFn({ - method: 'bridges:getUserBridge:doCreate', - params: [user, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return String(response.result); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts deleted file mode 100644 index c8ba7994638f9..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { IModifyExtender } from '@rocket.chat/apps-engine/definition/accessors/IModifyExtender'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender'; -import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender'; -import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; -import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -import * as Messenger from '../../messenger'; -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { MessageExtender } from '../extenders/MessageExtender'; -import { RoomExtender } from '../extenders/RoomExtender'; -import { VideoConferenceExtender } from '../extenders/VideoConferenceExtend'; -import { formatErrorResponse } from '../formatResponseErrorHandler'; - -export class ModifyExtender implements IModifyExtender { - constructor(private readonly senderFn: typeof Messenger.sendRequest) {} - - public async extendMessage(messageId: string, updater: IUser): Promise { - const result = await this.senderFn({ - method: 'bridges:getMessageBridge:doGetById', - params: [messageId, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - const msg = result.result as IMessage; - - msg.editor = updater; - msg.editedAt = new Date(); - - return new MessageExtender(msg); - } - - public async extendRoom(roomId: string, _updater: IUser): Promise { - const result = await this.senderFn({ - method: 'bridges:getRoomBridge:doGetById', - params: [roomId, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - const room = result.result as IRoom; - - room.updatedAt = new Date(); - - return new RoomExtender(room); - } - - public async extendVideoConference(id: string): Promise { - const result = await this.senderFn({ - method: 'bridges:getVideoConferenceBridge:doGetById', - params: [id, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - const call = result.result as VideoConference; - - call._updatedAt = new Date(); - - return new VideoConferenceExtender(call); - } - - public async finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise { - switch (extender.kind) { - case RocketChatAssociationModel.MESSAGE: - await this.senderFn({ - method: 'bridges:getMessageBridge:doUpdate', - params: [(extender as IMessageExtender).getMessage(), AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - break; - case RocketChatAssociationModel.ROOM: - await this.senderFn({ - method: 'bridges:getRoomBridge:doUpdate', - params: [ - (extender as IRoomExtender).getRoom(), - (extender as IRoomExtender).getUsernamesOfMembersBeingAdded(), - AppObjectRegistry.get('id'), - ], - }).catch((err) => { - throw formatErrorResponse(err); - }); - break; - case RocketChatAssociationModel.VIDEO_CONFERENCE: - await this.senderFn({ - method: 'bridges:getVideoConferenceBridge:doUpdate', - params: [(extender as IVideoConferenceExtender).getVideoConference(), AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - break; - default: - throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); - } - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts deleted file mode 100644 index 1c1ced9be5b59..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { UIHelper } from '@rocket.chat/apps/dist/server/misc/UIHelper'; -import type { IModifyUpdater } from '@rocket.chat/apps-engine/definition/accessors/IModifyUpdater'; -import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors/ILivechatUpdater'; -import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater'; -import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater'; -import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder'; -import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; - -import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; -import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; - -import * as Messenger from '../../messenger'; - -import { MessageBuilder } from '../builders/MessageBuilder'; -import { RoomBuilder } from '../builders/RoomBuilder'; -import { AppObjectRegistry } from '../../../AppObjectRegistry'; - -import { formatErrorResponse } from '../formatResponseErrorHandler'; - -export class ModifyUpdater implements IModifyUpdater { - private readonly livechatUpdater: ILivechatUpdater; - private readonly userUpdater: IUserUpdater; - private readonly messageUpdater: IMessageUpdater; - - constructor(private readonly senderFn: typeof Messenger.sendRequest) { - this.livechatUpdater = this.proxify('getLivechatUpdater'); - this.userUpdater = this.proxify('getUserUpdater'); - this.messageUpdater = this.proxify('getMessageUpdater'); - } - - private proxify(target: 'getLivechatUpdater' | 'getUserUpdater' | 'getMessageUpdater'): T { - return new Proxy( - { __kind: target }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getUpdater:${target}:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as T; - } - - public getLivechatUpdater(): ILivechatUpdater { - return this.livechatUpdater; - } - - public getUserUpdater(): IUserUpdater { - return this.userUpdater; - } - - public getMessageUpdater(): IMessageUpdater { - return this.messageUpdater; - } - - public async message(messageId: string, editor: IUser): Promise { - const response = await this.senderFn({ - method: 'bridges:getMessageBridge:doGetById', - params: [messageId, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - const builder = new MessageBuilder(response.result as IMessage); - - builder.setEditor(editor); - - return builder; - } - - public async room(roomId: string, _updater: IUser): Promise { - const response = await this.senderFn({ - method: 'bridges:getRoomBridge:doGetById', - params: [roomId, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return new RoomBuilder(response.result as IRoom); - } - - public finish(builder: IMessageBuilder | IRoomBuilder): Promise { - switch (builder.kind) { - case RocketChatAssociationModel.MESSAGE: - return this._finishMessage(builder as MessageBuilder); - case RocketChatAssociationModel.ROOM: - return this._finishRoom(builder as RoomBuilder); - default: - throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); - } - } - - private async _finishMessage(builder: MessageBuilder): Promise { - const result = builder.getMessage(); - - if (!result.id) { - throw new Error("Invalid message, can't update a message without an id."); - } - - if (!result.sender?.id) { - throw new Error('Invalid sender assigned to the message.'); - } - - if (result.blocks?.length) { - result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); - } - - const changes = { id: result.id, ...builder.getChanges() }; - - await this.senderFn({ - method: 'bridges:getMessageBridge:doUpdate', - params: [changes, AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - } - - private async _finishRoom(builder: RoomBuilder): Promise { - const room = builder.getRoom(); - - if (!room.id) { - throw new Error("Invalid room, can't update a room without an id."); - } - - if (!room.type) { - throw new Error('Invalid type assigned to the room.'); - } - - if (room.type !== RoomType.LIVE_CHAT) { - if (!room.creator || !room.creator.id) { - throw new Error('Invalid creator assigned to the room.'); - } - - if (!room.slugifiedName || !room.slugifiedName.trim()) { - throw new Error('Invalid slugifiedName assigned to the room.'); - } - } - - if (!room.displayName || !room.displayName.trim()) { - throw new Error('Invalid displayName assigned to the room.'); - } - - const changes = { id: room.id, ...builder.getChanges() }; - - await this.senderFn({ - method: 'bridges:getRoomBridge:doUpdate', - params: [changes, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/notifier.ts b/packages/apps/deno-runtime/lib/accessors/notifier.ts deleted file mode 100644 index 7dd317d3573d7..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/notifier.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors'; -import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; -import { TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { MessageBuilder } from './builders/MessageBuilder'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import * as Messenger from '../messenger'; -import { formatErrorResponse } from './formatResponseErrorHandler'; - -export class Notifier implements INotifier { - private senderFn: typeof Messenger.sendRequest; - - constructor(senderFn: typeof Messenger.sendRequest) { - this.senderFn = senderFn; - } - - public async notifyUser(user: IUser, message: IMessage): Promise { - if (!message.sender || !message.sender.id) { - const appUser = await this.getAppUser(); - - message.sender = appUser!; - } - - await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get('id')]); - } - - public async notifyRoom(room: IRoom, message: IMessage): Promise { - if (!message.sender || !message.sender.id) { - const appUser = await this.getAppUser(); - - message.sender = appUser!; - } - - await this.callMessageBridge('doNotifyRoom', [room, message, AppObjectRegistry.get('id')]); - } - - public async typing(options: ITypingOptions): Promise<() => Promise> { - options.scope = options.scope || TypingScope.Room; - - if (!options.username) { - const appUser = await this.getAppUser(); - options.username = (appUser && appUser.name) || ''; - } - - const appId = AppObjectRegistry.get('id'); - - await this.callMessageBridge('doTyping', [{ ...options, isTyping: true }, appId]); - - return async () => { - await this.callMessageBridge('doTyping', [{ ...options, isTyping: false }, appId]); - }; - } - - public getMessageBuilder(): IMessageBuilder { - return new MessageBuilder(); - } - - private async callMessageBridge(method: string, params: Array): Promise { - await this.senderFn({ - method: `bridges:getMessageBridge:${method}`, - params, - }).catch((err) => { - throw formatErrorResponse(err); - }); - } - - private async getAppUser(): Promise { - const response = await this.senderFn({ - method: 'bridges:getUserBridge:doGetAppUser', - params: [AppObjectRegistry.get('id')], - }).catch((err) => { - throw formatErrorResponse(err); - }); - - return response.result as IUser | undefined; - } -} diff --git a/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts deleted file mode 100644 index 52cca5f35cac6..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertEquals } from 'https://deno.land/std@0.203.0/assert/assert_equals.ts'; - -import { AppAccessors } from '../mod'; -import { AppObjectRegistry } from '../../../AppObjectRegistry'; - -describe('AppAccessors', () => { - let appAccessors: AppAccessors; - const senderFn = (r: object) => - Promise.resolve({ - id: Math.random().toString(36).substring(2), - jsonrpc: '2.0', - result: r, - serialize() { - return JSON.stringify(this); - }, - }); - - beforeEach(() => { - appAccessors = new AppAccessors(senderFn); - AppObjectRegistry.clear(); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('creates the correct format for IRead calls', async () => { - const roomRead = appAccessors.getReader().getRoomReader(); - const room = await roomRead.getById('123'); - - assertEquals(room, { - params: ['123'], - method: 'accessor:getReader:getRoomReader:getById', - }); - }); - - it('creates the correct format for IEnvironmentRead calls from IRead', async () => { - const reader = appAccessors.getReader().getEnvironmentReader().getEnvironmentVariables(); - const room = await reader.getValueByName('NODE_ENV'); - - assertEquals(room, { - params: ['NODE_ENV'], - method: 'accessor:getReader:getEnvironmentReader:getEnvironmentVariables:getValueByName', - }); - }); - - it('creates the correct format for IEvironmentRead calls', async () => { - const envRead = appAccessors.getEnvironmentRead(); - const env = await envRead.getServerSettings().getValueById('123'); - - assertEquals(env, { - params: ['123'], - method: 'accessor:getEnvironmentRead:getServerSettings:getValueById', - }); - }); - - it('creates the correct format for IEvironmentWrite calls', async () => { - const envRead = appAccessors.getEnvironmentWrite(); - const env = await envRead.getServerSettings().incrementValue('123', 6); - - assertEquals(env, { - params: ['123', 6], - method: 'accessor:getEnvironmentWrite:getServerSettings:incrementValue', - }); - }); - - it('creates the correct format for IConfigurationModify calls', async () => { - const configModify = appAccessors.getConfigurationModify(); - const command = await configModify.slashCommands.modifySlashCommand({ - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - }); - - assertEquals(command, { - params: [ - { - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - }, - ], - method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', - }); - }); - - it('correctly stores a reference to a slashcommand object and sends a request via proxy', async () => { - const configExtend = appAccessors.getConfigurationExtend(); - - const slashcommand = { - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - executor() { - return Promise.resolve(); - }, - }; - - const result = await configExtend.slashCommands.provideSlashCommand(slashcommand); - - assertEquals(AppObjectRegistry.get('slashcommand:test'), slashcommand); - - // The function will not be serialized and sent to the main process - delete result.params[0].executor; - - assertEquals(result, { - method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', - params: [ - { - command: 'test', - i18nDescription: 'test', - i18nParamsExample: 'test', - providesPreview: true, - }, - ], - }); - }); -}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts deleted file mode 100644 index 3133ced740f8a..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { assert, assertEquals, assertNotInstanceOf, assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; - -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { ModifyCreator } from '../modify/ModifyCreator'; - -describe('ModifyCreator', () => { - const senderFn = (r: any) => - Promise.resolve({ - id: Math.random().toString(36).substring(2), - jsonrpc: '2.0', - result: r, - serialize() { - return JSON.stringify(this); - }, - }); - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('id', 'deno-test'); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('sends the correct payload in the request to create a message', async () => { - const spying = spy(senderFn); - const modifyCreator = new ModifyCreator(spying); - const messageBuilder = modifyCreator.startMessage(); - - // Importing types from the Apps-Engine is problematic, so we'll go with `any` here - messageBuilder - .setRoom({ id: '123' } as any) - .setSender({ id: '456' } as any) - .setText('Hello World') - .setUsernameAlias('alias') - .setAvatarUrl('https://avatars.com/123'); - - // We can't get a legitimate return value here, so we ignore it - // but we need to know that the request sent was well formed - await modifyCreator.finish(messageBuilder); - - assertSpyCall(spying, 0, { - args: [ - { - method: 'bridges:getMessageBridge:doCreate', - params: [ - { - room: { id: '123' }, - sender: { id: '456' }, - text: 'Hello World', - alias: 'alias', - avatarUrl: 'https://avatars.com/123', - }, - 'deno-test', - ], - }, - ], - }); - }); - - it('sends the correct payload in the request to upload a buffer', async () => { - const modifyCreator = new ModifyCreator(senderFn); - - const result = await modifyCreator.getUploadCreator().uploadBuffer(new Uint8Array([1, 2, 3, 4]), 'text/plain'); - - assertEquals(result, { - method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', - params: [new Uint8Array([1, 2, 3, 4]), 'text/plain'], - }); - }); - - it('sends the correct payload in the request to create a visitor', async () => { - const modifyCreator = new ModifyCreator(senderFn); - - const result = (await modifyCreator.getLivechatCreator().createVisitor({ - token: 'random token', - username: 'random username for visitor', - name: 'Random Visitor', - })) as any; // We modified the send function so it changed the original return type of the function - - assertEquals(result, { - method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', - params: [ - { - token: 'random token', - username: 'random username for visitor', - name: 'Random Visitor', - }, - ], - }); - }); - - // This test is important because if we return a promise we break API compatibility - it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => { - const modifyCreator = new ModifyCreator(senderFn); - - const result = modifyCreator.getLivechatCreator().createToken(); - - assertNotInstanceOf(result, Promise); - assert(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`); - }); - - it('throws an error when a proxy method of getLivechatCreator fails', async () => { - const failingSenderFn = () => Promise.reject(new Error('Test error')); - const modifyCreator = new ModifyCreator(failingSenderFn); - const livechatCreator = modifyCreator.getLivechatCreator(); - - await assertRejects( - () => - livechatCreator.createAndReturnVisitor({ - token: 'visitor-token', - username: 'visitor-username', - name: 'Visitor Name', - }), - Error, - 'Test error', - ); - }); - - it('throws an instance of Error when getLivechatCreator fails with a specific error object', async () => { - const failingSenderFn = () => Promise.reject({ error: { message: 'Livechat method error' } }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const livechatCreator = modifyCreator.getLivechatCreator(); - - await assertRejects( - () => - livechatCreator.createVisitor({ - token: 'visitor-token', - username: 'visitor-username', - name: 'Visitor Name', - }), - Error, - 'Livechat method error', - ); - }); - - it('throws a default Error when getLivechatCreator fails with an unknown error object', async () => { - const failingSenderFn = () => Promise.reject({ error: {} }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const livechatCreator = modifyCreator.getLivechatCreator(); - - await assertRejects( - () => - livechatCreator.createVisitor({ - token: 'visitor-token', - username: 'visitor-username', - name: 'Visitor Name', - }), - Error, - 'An unknown error occurred', - ); - }); - - it('throws an error when a proxy method of getUploadCreator fails', async () => { - const failingSenderFn = () => Promise.reject(new Error('Upload error')); - const modifyCreator = new ModifyCreator(failingSenderFn); - const uploadCreator = modifyCreator.getUploadCreator(); - - await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([9, 10, 11, 12]), 'image/png'), Error, 'Upload error'); - }); - - it('throws an instance of Error when getUploadCreator fails with a specific error object', async () => { - const failingSenderFn = () => Promise.reject({ error: { message: 'Upload method error' } }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const uploadCreator = modifyCreator.getUploadCreator(); - - await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([1, 2, 3]), 'image/png'), Error, 'Upload method error'); - }); - - it('throws a default Error when getUploadCreator fails with an unknown error object', async () => { - const failingSenderFn = () => Promise.reject({ error: {} }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const uploadCreator = modifyCreator.getUploadCreator(); - - await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([1, 2, 3]), 'image/png'), Error, 'An unknown error occurred'); - }); - - it('throws an error when a proxy method of getEmailCreator fails', async () => { - const failingSenderFn = () => Promise.reject(new Error('Email error')); - const modifyCreator = new ModifyCreator(failingSenderFn); - const emailCreator = modifyCreator.getEmailCreator(); - - await assertRejects( - () => - emailCreator.send({ - to: 'test@example.com', - from: 'sender@example.com', - subject: 'Test Email', - text: 'This is a test email.', - }), - Error, - 'Email error', - ); - }); - - it('throws an instance of Error when getEmailCreator fails with a specific error object', async () => { - const failingSenderFn = () => Promise.reject({ error: { message: 'Email method error' } }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const emailCreator = modifyCreator.getEmailCreator(); - - await assertRejects( - () => - emailCreator.send({ - to: 'test@example.com', - from: 'sender@example.com', - subject: 'Test Email', - text: 'This is a test email.', - }), - Error, - 'Email method error', - ); - }); - - it('throws a default Error when getEmailCreator fails with an unknown error object', async () => { - const failingSenderFn = () => Promise.reject({ error: {} }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const emailCreator = modifyCreator.getEmailCreator(); - - await assertRejects( - () => - emailCreator.send({ - to: 'test@example.com', - from: 'sender@example.com', - subject: 'Test Email', - text: 'This is a test email.', - }), - Error, - 'An unknown error occurred', - ); - }); - - it('throws an error when a proxy method of getContactCreator fails', async () => { - const failingSenderFn = () => Promise.reject(new Error('Contact creation error')); - const modifyCreator = new ModifyCreator(failingSenderFn); - const contactCreator = modifyCreator.getContactCreator(); - - await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'Contact creation error'); - }); - - it('throws an instance of Error when getContactCreator fails with a specific error object', async () => { - const failingSenderFn = () => Promise.reject({ error: { message: 'Contact creation error' } }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const contactCreator = modifyCreator.getContactCreator(); - - await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'Contact creation error'); - }); - - it('throws a default Error when getContactCreator fails with an unknown error object', async () => { - const failingSenderFn = () => Promise.reject({ error: {} }); - const modifyCreator = new ModifyCreator(failingSenderFn); - const contactCreator = modifyCreator.getContactCreator(); - - await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'An unknown error occurred'); - }); -}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts deleted file mode 100644 index a636e5ba0470c..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertSpyCall, spy, stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; - -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { ModifyExtender } from '../modify/ModifyExtender'; -import jsonrpc from 'jsonrpc-lite'; - -describe('ModifyExtender', () => { - let extender: ModifyExtender; - - const senderFn = (r: any) => - Promise.resolve({ - id: Math.random().toString(36).substring(2), - jsonrpc: '2.0', - result: structuredClone(r), - serialize() { - return JSON.stringify(this); - }, - }); - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('id', 'deno-test'); - extender = new ModifyExtender(senderFn); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('correctly formats requests for the extend message requests', async () => { - const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); - - const messageExtender = await extender.extendMessage('message-id', { _id: 'user-id' } as any); - - assertSpyCall(_spy, 0, { - args: [ - { - method: 'bridges:getMessageBridge:doGetById', - params: ['message-id', 'deno-test'], - }, - ], - }); - - messageExtender.addCustomField('key', 'value'); - - await extender.finish(messageExtender); - - assertSpyCall(_spy, 1, { - args: [ - { - method: 'bridges:getMessageBridge:doUpdate', - params: [messageExtender.getMessage(), 'deno-test'], - }, - ], - }); - - _spy.restore(); - }); - - it('correctly formats requests for the extend room requests', async () => { - const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); - - const roomExtender = await extender.extendRoom('room-id', { _id: 'user-id' } as any); - - assertSpyCall(_spy, 0, { - args: [ - { - method: 'bridges:getRoomBridge:doGetById', - params: ['room-id', 'deno-test'], - }, - ], - }); - - roomExtender.addCustomField('key', 'value'); - - await extender.finish(roomExtender); - - assertSpyCall(_spy, 1, { - args: [ - { - method: 'bridges:getRoomBridge:doUpdate', - params: [roomExtender.getRoom(), [], 'deno-test'], - }, - ], - }); - - _spy.restore(); - }); - - it('correctly formats requests for the extend video conference requests', async () => { - const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); - - const videoConferenceExtender = await extender.extendVideoConference('video-conference-id'); - - assertSpyCall(_spy, 0, { - args: [ - { - method: 'bridges:getVideoConferenceBridge:doGetById', - params: ['video-conference-id', 'deno-test'], - }, - ], - }); - - videoConferenceExtender.setStatus(4); - - await extender.finish(videoConferenceExtender); - - assertSpyCall(_spy, 1, { - args: [ - { - method: 'bridges:getVideoConferenceBridge:doUpdate', - params: [videoConferenceExtender.getVideoConference(), 'deno-test'], - }, - ], - }); - - _spy.restore(); - }); - - describe('Error Handling', () => { - describe('extendMessage', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - extender, - 'senderFn' as keyof ModifyExtender, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); - - await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - - describe('extendRoom', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - extender, - 'senderFn' as keyof ModifyExtender, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); - - await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - - describe('extendVideoConference', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - extender, - 'senderFn' as keyof ModifyExtender, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); - - await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - - describe('finish', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - extender, - 'senderFn' as keyof ModifyExtender, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); - - await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - }); -}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts deleted file mode 100644 index dd2bddc4ab363..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { assertSpyCall, spy, stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import { assertEquals, assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; - -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { ModifyUpdater } from '../modify/ModifyUpdater'; -import { RoomBuilder } from '../builders/RoomBuilder'; -import jsonrpc from 'jsonrpc-lite'; - -describe('ModifyUpdater', () => { - let modifyUpdater: ModifyUpdater; - - const senderFn = (r: any) => - Promise.resolve({ - id: Math.random().toString(36).substring(2), - jsonrpc: '2.0', - result: structuredClone(r), - serialize() { - return JSON.stringify(this); - }, - }); - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('id', 'deno-test'); - modifyUpdater = new ModifyUpdater(senderFn); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - it('correctly formats requests for the update message flow', async () => { - const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); - - const messageBuilder = await modifyUpdater.message('123', { id: '456' } as any); - - assertSpyCall(_spy, 0, { - args: [ - { - method: 'bridges:getMessageBridge:doGetById', - params: ['123', 'deno-test'], - }, - ], - }); - - messageBuilder.setUpdateData( - { - id: '123', - room: { id: '123' }, - sender: { id: '456' }, - text: 'Hello World', - }, - { - id: '456', - }, - ); - - await modifyUpdater.finish(messageBuilder); - - assertSpyCall(_spy, 1, { - args: [ - { - method: 'bridges:getMessageBridge:doUpdate', - params: [{ id: '123', ...messageBuilder.getChanges() }, 'deno-test'], - }, - ], - }); - - _spy.restore(); - }); - - it('correctly formats requests for the update room flow', async () => { - const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); - - const roomBuilder = (await modifyUpdater.room('123', { id: '456' } as any)) as RoomBuilder; - - assertSpyCall(_spy, 0, { - args: [ - { - method: 'bridges:getRoomBridge:doGetById', - params: ['123', 'deno-test'], - }, - ], - }); - - roomBuilder.setData({ - id: '123', - type: 'c', - displayName: 'Test Room', - slugifiedName: 'test-room', - creator: { id: '456' }, - }); - - roomBuilder.setMembersToBeAddedByUsernames(['username1', 'username2']); - - // We need to sneak in the id as the `modifyUpdater.room` call won't have legitimate data - roomBuilder.getRoom().id = '123'; - - await modifyUpdater.finish(roomBuilder); - - assertSpyCall(_spy, 1, { - args: [ - { - method: 'bridges:getRoomBridge:doUpdate', - params: [{ id: '123', ...roomBuilder.getChanges() }, roomBuilder.getMembersToBeAddedUsernames(), 'deno-test'], - }, - ], - }); - }); - - it('correctly formats requests to UserUpdater methods', async () => { - const result = (await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as any, 'Hello World')) as any; - - assertEquals(result, { - method: 'accessor:getModifier:getUpdater:getUserUpdater:updateStatusText', - params: [{ id: '123' }, 'Hello World'], - }); - }); - - it('correctly formats requests to LivechatUpdater methods', async () => { - const result = (await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as any, 'close it!')) as any; - - assertEquals(result, { - method: 'accessor:getModifier:getUpdater:getLivechatUpdater:closeRoom', - params: [{ id: '123' }, 'close it!'], - }); - }); - - it('correctly formats requests to MessageUpdater methods', async () => { - const result = (await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:')) as any; - - assertEquals(result, { - method: 'accessor:getModifier:getUpdater:getMessageUpdater:addReaction', - params: ['message-id', 'user-id', ':smile:'], - }); - }); - - describe('Error Handling', () => { - describe('message', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - modifyUpdater, - 'senderFn' as keyof ModifyUpdater, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); - - await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - - describe('room', () => { - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - modifyUpdater, - 'senderFn' as keyof ModifyUpdater, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); - - await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - - describe('finish', () => { - const messageUpdater = { - kind: 'message', - getMessage: () => ({ - id: 'message-id', - sender: { id: 'sender-id' }, - }), - getChanges: () => ({ - id: 'message-id', - sender: { id: 'sender-id' }, - }), - } as any; - - it('throws an instance of Error when senderFn throws an error', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); - - await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { - const _stub = stub( - modifyUpdater, - 'senderFn' as keyof ModifyUpdater, - () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, - ); - - await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'unit-test-error'); - - _stub.restore(); - }); - - it('throws an instance of Error when senderFn throws an unknown value', async () => { - const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); - - await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); - }); -}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts deleted file mode 100644 index 45e9fac18c560..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals, assertInstanceOf, assertStrictEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import * as jsonrpc from 'jsonrpc-lite'; - -import { formatErrorResponse } from '../formatResponseErrorHandler'; - -describe('formatErrorResponse', () => { - describe('JSON-RPC ErrorObject handling', () => { - it('formats ErrorObject instances correctly', () => { - const errorObject = jsonrpc.error('test-id', new jsonrpc.JsonRpcError('Test error message', 1000)); - const result = formatErrorResponse(errorObject); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'Test error message'); - }); - - it('formats objects with error.message structure', () => { - const errorLikeObject = { - error: { - message: 'Custom error message', - code: 404, - }, - }; - const result = formatErrorResponse(errorLikeObject); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'Custom error message'); - }); - - it('handles nested error objects with complex structure', () => { - const complexError = { - error: { - message: 'Database connection failed', - details: { - host: 'localhost', - port: 5432, - }, - }, - id: 'req-123', - }; - const result = formatErrorResponse(complexError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'Database connection failed'); - }); - - it('handles error objects with empty message', () => { - const emptyMessageError = { - error: { - message: '', - code: 500, - }, - }; - const result = formatErrorResponse(emptyMessageError); - - assertInstanceOf(result, Error); - assertEquals(result.message, ''); - }); - }); - - describe('Error instance passthrough', () => { - it('returns existing Error instances unchanged', () => { - const originalError = new Error('Original error message'); - const result = formatErrorResponse(originalError); - - assertStrictEquals(result, originalError); - assertEquals(result.message, 'Original error message'); - }); - - it('returns custom Error subclasses unchanged', () => { - class CustomError extends Error { - constructor( - message: string, - public code: number, - ) { - super(message); - this.name = 'CustomError'; - } - } - - const customError = new CustomError('Custom error', 404); - const result = formatErrorResponse(customError); - - assertStrictEquals(result, customError); - assertEquals(result.message, 'Custom error'); - assertEquals((result as CustomError).code, 404); - }); - - it('handles Error instances with additional properties', () => { - const errorWithProps = new Error('Error with props') as any; - errorWithProps.statusCode = 500; - errorWithProps.details = { reason: 'timeout' }; - - const result = formatErrorResponse(errorWithProps); - - assertStrictEquals(result, errorWithProps); - assertEquals(result.message, 'Error with props'); - assertEquals((result as any).statusCode, 500); - }); - }); - - describe('Unknown error handling', () => { - it('wraps string errors with default message and cause', () => { - const stringError = 'Simple string error'; - const result = formatErrorResponse(stringError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, stringError); - }); - - it('wraps number errors with default message and cause', () => { - const numberError = 404; - const result = formatErrorResponse(numberError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, numberError); - }); - - it('wraps boolean errors with default message and cause', () => { - const booleanError = false; - const result = formatErrorResponse(booleanError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, booleanError); - }); - - it('wraps null with default message and cause', () => { - const result = formatErrorResponse(null); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, null); - }); - - it('wraps undefined with default message and cause', () => { - const result = formatErrorResponse(undefined); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, undefined); - }); - - it('wraps arrays with default message and cause', () => { - const arrayError = ['error', 'details']; - const result = formatErrorResponse(arrayError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, arrayError); - }); - - it('wraps functions with default message and cause', () => { - const functionError = () => 'error'; - const result = formatErrorResponse(functionError); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, functionError); - }); - - it('wraps plain objects without error.message with default message and cause', () => { - const plainObject = { - status: 'failed', - reason: 'timeout', - data: { id: 123 }, - }; - const result = formatErrorResponse(plainObject); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, plainObject); - }); - - it('wraps objects with error property but no message with default message and cause', () => { - const errorObjectNoMessage = { - error: { - code: 500, - details: 'Internal server error', - }, - }; - const result = formatErrorResponse(errorObjectNoMessage); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - assertEquals(result.cause, errorObjectNoMessage); - }); - }); - - it('ensures all returned values are proper Error instances', () => { - const testCases = ['string error', 123, null, undefined, { error: { message: 'test' } }, new Error('test'), { plain: 'object' }]; - - for (const testCase of testCases) { - const result = formatErrorResponse(testCase); - assertInstanceOf(result, Error, `Failed for input: ${JSON.stringify(testCase)}`); - } - }); - - it('prevents "[object Object]" error messages for plain objects', () => { - const plainObject = { status: 'error', code: 500 }; - const result = formatErrorResponse(plainObject); - - assertInstanceOf(result, Error); - assertEquals(result.message, 'An unknown error occurred'); - // Ensure the message is not "[object Object]" - assertEquals(result.message !== '[object Object]', true); - }); -}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts deleted file mode 100644 index 732ffe6c5d1f0..0000000000000 --- a/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it, afterAll } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; - -import { Http } from '../http'; -import { AppObjectRegistry } from '../../../AppObjectRegistry'; -import { stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; - -describe('Http accessor error handling integration', () => { - let http: Http; - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('id', 'test-app-id'); - - const mockHttpExtend = { - getDefaultHeaders: () => new Map(), - getDefaultParams: () => new Map(), - getPreRequestHandlers: () => [], - getPreResponseHandlers: () => [], - }; - - const mockRead = {}; - const mockPersistence = {}; - - http = new Http(mockRead as any, mockPersistence as any, mockHttpExtend as any, () => Promise.resolve({}) as any); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - }); - - describe('HTTP method error handling', () => { - it('formats JSON-RPC errors correctly for GET requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => - Promise.reject({ - error: { - message: 'HTTP GET request failed', - code: 404, - }, - }), - ); - - await assertRejects(() => http.get('https://api.example.com/data'), Error, 'HTTP GET request failed'); - - _stub.restore(); - }); - - it('formats JSON-RPC errors correctly for POST requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => - Promise.reject({ - error: { - message: 'HTTP POST request validation failed', - code: 400, - }, - }), - ); - - await assertRejects( - () => http.post('https://api.example.com/create', { data: { name: 'test' } }), - Error, - 'HTTP POST request validation failed', - ); - - _stub.restore(); - }); - - it('formats JSON-RPC errors correctly for PUT requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => - Promise.reject({ - error: { - message: 'HTTP PUT request unauthorized', - code: 401, - }, - }), - ); - - await assertRejects( - () => http.put('https://api.example.com/update/123', { data: { name: 'updated' } }), - Error, - 'HTTP PUT request unauthorized', - ); - - _stub.restore(); - }); - - it('formats JSON-RPC errors correctly for DELETE requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => - Promise.reject({ - error: { - message: 'HTTP DELETE request forbidden', - code: 403, - }, - }), - ); - - await assertRejects(() => http.del('https://api.example.com/delete/123'), Error, 'HTTP DELETE request forbidden'); - - _stub.restore(); - }); - - it('formats JSON-RPC errors correctly for PATCH requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => - Promise.reject({ - error: { - message: 'HTTP PATCH request conflict', - code: 409, - }, - }), - ); - - await assertRejects( - () => http.patch('https://api.example.com/patch/123', { data: { status: 'active' } }), - Error, - 'HTTP PATCH request conflict', - ); - - _stub.restore(); - }); - }); - - describe('Error instance passthrough', () => { - it('passes through existing Error instances unchanged for HTTP requests', async () => { - const originalError = new Error('Network timeout error'); - const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(originalError)); - - await assertRejects(() => http.get('https://api.example.com/data'), Error, 'Network timeout error'); - - _stub.restore(); - }); - }); - - describe('Unknown error handling', () => { - it('wraps unknown object errors with default message for HTTP requests', async () => { - const unknownError = { - status: 'failed', - details: 'Something went wrong', - timestamp: Date.now(), - }; - const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(unknownError)); - - await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - - it('wraps string errors with default message for HTTP requests', async () => { - const stringError = 'Connection refused'; - const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(stringError)); - - await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - - it('wraps null/undefined errors with default message for HTTP requests', async () => { - const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(null)); - - await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); - - _stub.restore(); - }); - }); -}); diff --git a/packages/apps/deno-runtime/lib/ast/mod.ts b/packages/apps/deno-runtime/lib/ast/mod.ts deleted file mode 100644 index 234cfd3e784b2..0000000000000 --- a/packages/apps/deno-runtime/lib/ast/mod.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AnyNode, Node } from 'acorn'; -import { parse } from 'acorn'; -import { fullAncestor } from 'acorn-walk'; -import { generate } from 'astring'; - -import * as operations from './operations'; -import type { WalkerState } from './operations'; - -function fixAst(ast: Node): boolean { - const pendingOperations = [ - operations.fixLivechatIsOnlineCalls, - operations.checkReassignmentOfModifiedIdentifiers, - operations.fixRoomUsernamesCalls, - ]; - - // Have we touched the tree? - let isModified = false; - - while (pendingOperations.length) { - const ops = pendingOperations.splice(0); - const state: WalkerState = { - isModified: false, - functionIdentifiers: new Set(), - }; - - fullAncestor( - ast, - (node, state, ancestors, type) => { - ops.forEach((operation) => operation(node as AnyNode, state as WalkerState, ancestors as AnyNode[], type)); - }, - undefined, - state, - ); - - if (state.isModified) { - isModified = true; - } - - if (state.functionIdentifiers.size) { - pendingOperations.push( - operations.buildFixModifiedFunctionsOperation(state.functionIdentifiers), - operations.checkReassignmentOfModifiedIdentifiers, - ); - } - } - - return isModified; -} - -export function fixBrokenSynchronousAPICalls(appSource: string): string { - const astRootNode = parse(appSource, { - // Latest ecma version supported by this version of acorn. - ecmaVersion: 'latest', - // Allow everything, we don't want to complain if code is badly written - // Also, since the code itself has been transpiled, the chance of getting - // shenanigans is lower - allowReserved: true, - allowReturnOutsideFunction: true, - allowImportExportEverywhere: true, - allowAwaitOutsideFunction: true, - allowSuperOutsideMethod: true, - }); - - if (fixAst(astRootNode)) { - return generate(astRootNode); - } - - return appSource; -} diff --git a/packages/apps/deno-runtime/lib/ast/operations.ts b/packages/apps/deno-runtime/lib/ast/operations.ts deleted file mode 100644 index 4d74cc5efa059..0000000000000 --- a/packages/apps/deno-runtime/lib/ast/operations.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { AnyNode, AssignmentExpression, AwaitExpression, Expression, Function, Identifier, MethodDefinition, Property } from 'acorn'; -import type { FullAncestorWalkerCallback } from 'acorn-walk'; - -export type WalkerState = { - isModified: boolean; - functionIdentifiers: Set; -}; - -export function getFunctionIdentifier(ancestors: AnyNode[], functionNodeIndex: number) { - const parent = ancestors[functionNodeIndex - 1]; - - // If there is a parent node and it's not a computed property, we can try to - // extract an identifier for our function from it. This needs to be done first - // because when functions are assigned to named symbols, this will be the only - // way to call it, even if the function itself has an identifier - // Consider the following block: - // - // const foo = function bar() {} - // - // Even though the function itself has a name, the only way to call it in the - // program is wiht `foo()` - if (parent && !(parent as Property | MethodDefinition).computed) { - // Several node types can have an id prop of type Identifier - const { id } = parent as unknown as { id?: Identifier }; - if (id?.type === 'Identifier') { - return id.name; - } - - // Usually assignments to object properties (MethodDefinition, Property) - const { key } = parent as MethodDefinition | Property; - if (key?.type === 'Identifier') { - return key.name; - } - - // Variable assignments have left hand side that can be used as Identifier - const { left } = parent as AssignmentExpression; - - // Simple assignment: `const fn = () => {}` - if (left?.type === 'Identifier') { - return left.name; - } - - // Object property assignment: `obj.fn = () => {}` - if (left?.type === 'MemberExpression' && !left.computed) { - return (left.property as Identifier).name; - } - } - - // nodeIndex needs to be the index of a Function node (either FunctionDeclaration or FunctionExpression) - const currentNode = ancestors[functionNodeIndex] as Function; - - // Function declarations or expressions can be directly named - if (currentNode.id?.type === 'Identifier') { - return currentNode.id.name; - } -} - -export function wrapWithAwait(node: Expression) { - if (!node.type.endsWith('Expression')) { - throw new Error(`Can't wrap "${node.type}" with await`); - } - - const innerNode: Expression = { ...node }; - - node.type = 'AwaitExpression'; - // starting here node has become an AwaitExpression - (node as AwaitExpression).argument = innerNode; - - Object.keys(node).forEach((key) => !['type', 'argument'].includes(key) && delete node[key as keyof AnyNode]); -} - -export function asyncifyScope(ancestors: AnyNode[], state: WalkerState) { - const functionNodeIndex = ancestors.findLastIndex((n) => 'async' in n); - if (functionNodeIndex === -1) return; - - // At this point this is a node with an "async" property, so it has to be - // of type Function - let TS know about that - const functionScopeNode = ancestors[functionNodeIndex] as Function; - - if (functionScopeNode.async) { - return; - } - - functionScopeNode.async = true; - - // If the parent of a function node is a call expression, we're talking about an IIFE - // Should we care about this case as well? - // const parentNode = ancestors[functionScopeIndex-1]; - // if (parentNode?.type === 'CallExpression' && ancestors[functionScopeIndex-2] && ancestors[functionScopeIndex-2].type !== 'AwaitExpression') { - // pendingOperations.push(buildFunctionPredicate(getFunctionIdentifier(ancestors, functionScopeIndex-2))); - // } - - const identifier = getFunctionIdentifier(ancestors, functionNodeIndex); - - // We can't fix calls of functions which name we can't determine at compile time - if (!identifier) return; - - state.functionIdentifiers.add(identifier); -} - -export function buildFixModifiedFunctionsOperation(functionIdentifiers: Set): FullAncestorWalkerCallback { - return function _fixModifiedFunctionsOperation(node, state, ancestors) { - if (node.type !== 'CallExpression') return; - - // This node is a simple call to a function, like `fn()` - let isWrappable = node.callee.type === 'Identifier' && functionIdentifiers.has(node.callee.name); - - // This node is a call to an object property or instance method, like `obj.fn()`, but not computed like `obj[fn]()` - isWrappable ||= node.callee.type === 'MemberExpression' && - !node.callee.computed && - node.callee.property?.type === 'Identifier' && - functionIdentifiers.has(node.callee.property.name); - - // This is a weird dereferencing technique used by bundlers, and since we'll be dealing with bundled sources we have to check for it - // e.g. `r=(0,fn)(e)` - if (!isWrappable && node.callee.type === 'SequenceExpression') { - const [, secondExpression] = node.callee.expressions; - isWrappable = secondExpression?.type === 'Identifier' && functionIdentifiers.has(secondExpression.name); - isWrappable ||= secondExpression?.type === 'MemberExpression' && - !secondExpression.computed && - secondExpression.property.type === 'Identifier' && - functionIdentifiers.has(secondExpression.property.name); - } - - if (!isWrappable) return; - - // ancestors[ancestors.length-1] === node, so here we're checking for parent node - const parentNode = ancestors[ancestors.length - 2]; - if (!parentNode || parentNode.type === 'AwaitExpression') return; - - wrapWithAwait(node); - asyncifyScope(ancestors, state); - - state.isModified = true; - }; -} - -export const checkReassignmentOfModifiedIdentifiers: FullAncestorWalkerCallback = ( - node, - { functionIdentifiers }, - _ancestors, -) => { - if (node.type === 'AssignmentExpression') { - if (node.operator !== '=') return; - - let identifier = ''; - - if (node.left.type === 'Identifier') identifier = node.left.name; - - if (node.left.type === 'MemberExpression' && !node.left.computed) { - identifier = (node.left.property as Identifier).name; - } - - if (!identifier || node.right.type !== 'Identifier' || !functionIdentifiers.has(node.right.name)) return; - - functionIdentifiers.add(identifier); - - return; - } - - if (node.type === 'VariableDeclarator') { - if (node.id.type !== 'Identifier' || functionIdentifiers.has(node.id.name)) return; - - if (node.init?.type !== 'Identifier' || !functionIdentifiers.has(node.init?.name)) return; - - functionIdentifiers.add(node.id.name); - - return; - } - - // "Property" is for plain objects, "PropertyDefinition" is for classes - // but both share the same structure - if (node.type === 'Property' || node.type === 'PropertyDefinition') { - if (node.key.type !== 'Identifier' || functionIdentifiers.has(node.key.name)) return; - - if (node.value?.type !== 'Identifier' || !functionIdentifiers.has(node.value.name)) return; - - functionIdentifiers.add(node.key.name); - } -}; - -export const fixLivechatIsOnlineCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { - if (node.type !== 'MemberExpression' || node.computed) return; - - if ((node.property as Identifier).name !== 'isOnline') return; - - if (node.object.type !== 'CallExpression') return; - - if (node.object.callee.type !== 'MemberExpression') return; - - if ((node.object.callee.property as Identifier).name !== 'getLivechatReader') return; - - let parentIndex = ancestors.length - 2; - let targetNode = ancestors[parentIndex]; - - if (targetNode.type !== 'CallExpression') { - targetNode = node; - } else { - parentIndex--; - } - - // If we're already wrapped with an await, nothing to do - if (ancestors[parentIndex].type === 'AwaitExpression') return; - - // If we're in the middle of a chained member access, we can't wrap with await - if (ancestors[parentIndex].type === 'MemberExpression') return; - - wrapWithAwait(targetNode); - asyncifyScope(ancestors, state); - - state.isModified = true; -}; - -export const fixRoomUsernamesCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { - if (node.type !== 'MemberExpression' || node.computed) return; - - if ((node.property as Identifier).name !== 'usernames') return; - - let parentIndex = ancestors.length - 2; - let targetNode = ancestors[parentIndex]; - - if (targetNode.type !== 'CallExpression') { - targetNode = node; - } else { - parentIndex--; - } - - // If we're already wrapped with an await, nothing to do - if (ancestors[parentIndex].type === 'AwaitExpression') return; - - wrapWithAwait(targetNode); - asyncifyScope(ancestors, state); - - state.isModified = true; -}; diff --git a/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts b/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts deleted file mode 100644 index 8a283c6903a67..0000000000000 --- a/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts +++ /dev/null @@ -1,515 +0,0 @@ -import type { AnyNode, ClassDeclaration, ExpressionStatement, FunctionDeclaration, VariableDeclaration } from 'acorn'; - -/** - * Partial AST blocks to support testing. - * `start` and `end` properties are omitted for brevity. - */ - -type TestNodeExcerpt = { - code: string; - node: N; -}; - -const startEnd = { start: 0, end: 0 }; - -export const FunctionDeclarationFoo: TestNodeExcerpt = { - code: 'function foo() {}', - node: { - type: 'FunctionDeclaration', - id: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [], - ...startEnd, - }, - ...startEnd, - }, -}; - -export const ConstFooAssignedFunctionExpression: TestNodeExcerpt = { - code: 'const foo = function() {}', - node: { - type: 'VariableDeclaration', - kind: 'const', - declarations: [ - { - type: 'VariableDeclarator', - id: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - init: { - type: 'FunctionExpression', - id: null, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [], - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, -}; - -export const AssignmentExpressionOfArrowFunctionToFooIdentifier: TestNodeExcerpt = { - code: 'foo = () => {}', - node: { - type: 'ExpressionStatement', - expression: { - type: 'AssignmentExpression', - operator: '=', - left: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - right: { - type: 'ArrowFunctionExpression', - id: null, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [], - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, -}; - -export const AssignmentExpressionOfNamedFunctionToFooMemberExpression: TestNodeExcerpt = { - code: 'obj.foo = function bar() {}', - node: { - type: 'ExpressionStatement', - expression: { - type: 'AssignmentExpression', - operator: '=', - left: { - type: 'MemberExpression', - object: { - type: 'Identifier', - name: 'a', - ...startEnd, - }, - property: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - computed: false, - optional: false, - ...startEnd, - }, - right: { - type: 'FunctionExpression', - id: null, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [], - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, -}; - -export const MethodDefinitionOfFooInClassBar: TestNodeExcerpt = { - code: 'class Bar { foo() {} }', - node: { - type: 'ClassDeclaration', - id: { - type: 'Identifier', - name: 'Bar', - ...startEnd, - }, - superClass: null, - body: { - type: 'ClassBody', - body: [ - { - type: 'MethodDefinition', - key: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - value: { - type: 'FunctionExpression', - id: null, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [], - ...startEnd, - }, - ...startEnd, - }, - kind: 'method', - computed: false, - static: false, - ...startEnd, - }, - ], - ...startEnd, - }, - ...startEnd, - }, -}; - -export const SimpleCallExpressionOfFoo: TestNodeExcerpt = { - code: 'foo()', - node: { - type: 'ExpressionStatement', - expression: { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - arguments: [], - optional: false, - ...startEnd, - }, - ...startEnd, - }, -}; - -export const SyncFunctionDeclarationWithAsyncCallExpression: TestNodeExcerpt = { - // NOTE: this is invalid syntax, it won't be parsed by acorn - // but it can be an intermediary state of the AST after we run - // `wrapWithAwait` on "bar" call expressions, for instance - code: 'function foo() { return () => await bar() }', - node: { - type: 'FunctionDeclaration', - id: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [ - { - type: 'ReturnStatement', - argument: { - type: 'ArrowFunctionExpression', - id: null, - expression: true, - generator: false, - async: false, - params: [], - body: { - type: 'AwaitExpression', - argument: { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - arguments: [], - optional: false, - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, - ...startEnd, - }, -}; - -export const AssignmentOfFooToBar: TestNodeExcerpt = { - code: 'bar = foo', - node: { - type: 'ExpressionStatement', - expression: { - type: 'AssignmentExpression', - operator: '=', - left: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - right: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, -}; - -export const AssignmentOfFooToBarMemberExpression: TestNodeExcerpt = { - code: 'obj.bar = foo', - node: { - type: 'ExpressionStatement', - expression: { - type: 'AssignmentExpression', - operator: '=', - left: { - type: 'MemberExpression', - computed: false, - optional: false, - object: { - type: 'Identifier', - name: 'obj', - ...startEnd, - }, - property: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - ...startEnd, - }, - right: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, -}; - -export const AssignmentOfFooToBarVariableDeclarator: TestNodeExcerpt = { - code: 'const bar = foo', - node: { - type: 'VariableDeclaration', - kind: 'const', - declarations: [ - { - type: 'VariableDeclarator', - id: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - init: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, -}; - -export const AssignmentOfFooToBarPropertyDefinition: TestNodeExcerpt = { - code: 'class baz { bar = foo }', - node: { - type: 'ClassDeclaration', - id: { - type: 'Identifier', - name: 'baz', - ...startEnd, - }, - superClass: null, - body: { - type: 'ClassBody', - body: [ - { - type: 'PropertyDefinition', - static: false, - computed: false, - key: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - value: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, - ...startEnd, - }, -}; - -const fixSimpleCallExpressionCode = ` -function bar() { - const a = foo(); - - return a; -}`; - -export const FixSimpleCallExpression: TestNodeExcerpt = { - code: fixSimpleCallExpressionCode, - node: { - type: 'FunctionDeclaration', - id: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - expression: false, - generator: false, - async: false, - params: [], - body: { - type: 'BlockStatement', - body: [ - { - type: 'VariableDeclaration', - kind: 'const', - declarations: [ - { - type: 'VariableDeclarator', - id: { - type: 'Identifier', - name: 'a', - ...startEnd, - }, - init: { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - arguments: [], - optional: false, - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, - { - type: 'ReturnStatement', - argument: { - type: 'Identifier', - name: 'a', - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, - ...startEnd, - }, -}; - -export const ArrowFunctionDerefCallExpression: TestNodeExcerpt = { - // NOTE: this call strategy is widely used by bundlers; it's used to sever the `this` - // reference in the method from the object that contains it. This is mostly because - // the bundler wants to ensure that it does not messes up the bindings in the code it - // generates. - // - // This would be similar to doing `foo.call(undefined)` - code: 'const bar = () => (0, e.foo)();', - node: { - type: 'VariableDeclaration', - kind: 'const', - declarations: [ - { - type: 'VariableDeclarator', - id: { - type: 'Identifier', - name: 'bar', - ...startEnd, - }, - init: { - type: 'ArrowFunctionExpression', - id: null, - expression: true, - generator: false, - async: false, - params: [], - body: { - type: 'CallExpression', - optional: false, - arguments: [], - callee: { - type: 'SequenceExpression', - expressions: [ - { - type: 'Literal', - value: 0, - ...startEnd, - }, - { - type: 'MemberExpression', - object: { - type: 'Identifier', - name: 'e', - ...startEnd, - }, - property: { - type: 'Identifier', - name: 'foo', - ...startEnd, - }, - computed: false, - optional: false, - ...startEnd, - }, - ], - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ...startEnd, - }, - ], - ...startEnd, - }, -}; diff --git a/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts b/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts deleted file mode 100644 index db74a92cda5bb..0000000000000 --- a/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { assertEquals, assertThrows } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; - -import { - asyncifyScope, - buildFixModifiedFunctionsOperation, - checkReassignmentOfModifiedIdentifiers, - getFunctionIdentifier, - WalkerState, - wrapWithAwait, -} from '../operations'; -import { - ArrowFunctionDerefCallExpression, - AssignmentExpressionOfArrowFunctionToFooIdentifier, - AssignmentExpressionOfNamedFunctionToFooMemberExpression, - AssignmentOfFooToBar, - AssignmentOfFooToBarMemberExpression, - AssignmentOfFooToBarPropertyDefinition, - AssignmentOfFooToBarVariableDeclarator, - ConstFooAssignedFunctionExpression, - FixSimpleCallExpression, - FunctionDeclarationFoo, - MethodDefinitionOfFooInClassBar, - SimpleCallExpressionOfFoo, - SyncFunctionDeclarationWithAsyncCallExpression, -} from './data/ast_blocks'; -import { - AnyNode, - ArrowFunctionExpression, - AssignmentExpression, - AwaitExpression, - Expression, - MethodDefinition, - ReturnStatement, - VariableDeclaration, -} from 'acorn'; -import { assertNotEquals } from 'https://deno.land/std@0.203.0/assert/assert_not_equals.ts'; - -describe('getFunctionIdentifier', () => { - it(`identifies the name "foo" for the code \`${FunctionDeclarationFoo.code}\``, () => { - // ancestors array is built by the walking lib - const nodeAncestors = [FunctionDeclarationFoo.node]; - const functionNodeIndex = 0; - assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); - }); - - it(`identifies the name "foo" for the code \`${ConstFooAssignedFunctionExpression.code}\``, () => { - // ancestors array is built by the walking lib - const nodeAncestors = [ - ConstFooAssignedFunctionExpression.node, // VariableDeclaration - ConstFooAssignedFunctionExpression.node.declarations[0], // VariableDeclarator - ConstFooAssignedFunctionExpression.node.declarations[0].init!, // FunctionExpression - ]; - const functionNodeIndex = 2; - assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); - }); - - it(`identifies the name "foo" for the code \`${AssignmentExpressionOfArrowFunctionToFooIdentifier.code}\``, () => { - // ancestors array is built by the walking lib - const nodeAncestors = [ - AssignmentExpressionOfArrowFunctionToFooIdentifier.node, // ExpressionStatement - AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression, // AssignmentExpression - (AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression as AssignmentExpression).right, // ArrowFunctionExpression - ]; - const functionNodeIndex = 2; - assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); - }); - - it(`identifies the name "foo" for the code \`${AssignmentExpressionOfNamedFunctionToFooMemberExpression.code}\``, () => { - // ancestors array is built by the walking lib - const nodeAncestors = [ - AssignmentExpressionOfNamedFunctionToFooMemberExpression.node, // ExpressionStatement - AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression, // AssignmentExpression - (AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression as AssignmentExpression).right, // FunctionExpression - ]; - const functionNodeIndex = 2; - assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); - }); - - it(`identifies the name "foo" for the code \`${MethodDefinitionOfFooInClassBar.code}\``, () => { - // ancestors array is built by the walking lib - const nodeAncestors = [ - MethodDefinitionOfFooInClassBar.node, // ClassDeclaration - MethodDefinitionOfFooInClassBar.node.body, // ClassBody - MethodDefinitionOfFooInClassBar.node.body!.body[0], // MethodDefinition - (MethodDefinitionOfFooInClassBar.node.body!.body[0] as MethodDefinition).value, // FunctionExpression - ]; - const functionNodeIndex = 3; - assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); - }); -}); - -describe('wrapWithAwait', () => { - it('wraps a call expression with await', () => { - const node = structuredClone(SimpleCallExpressionOfFoo.node.expression); - wrapWithAwait(node); - - assertEquals('AwaitExpression', node.type); - assertNotEquals(SimpleCallExpressionOfFoo.node.expression.type, node.type); - assertEquals(SimpleCallExpressionOfFoo.node.expression, (node as AwaitExpression).argument); - }); - - it('throws if node is not an expression', () => { - const node = structuredClone(SimpleCallExpressionOfFoo.node); - assertThrows(() => wrapWithAwait(node as unknown as Expression)); - }); -}); - -describe('asyncifyScope', () => { - it('makes only the first function scope async', () => { - const node = structuredClone(SyncFunctionDeclarationWithAsyncCallExpression.node); - const ancestors: AnyNode[] = [ - node, // FunctionDeclaration - node.body, // BlockStatement - node.body!.body[0], // ReturnStatement - (node.body!.body[0] as ReturnStatement).argument!, // ArrowFunctionExpression - ((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body, // AwaitExpression - (((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body as AwaitExpression).argument, // CallExpression - ]; - const state: WalkerState = { - isModified: false, - functionIdentifiers: new Set(), - }; - - asyncifyScope(ancestors, state); - - // Assert the function did indeed change the expression to async - assertEquals(((node.body.body[0] as ReturnStatement).argument as ArrowFunctionExpression).async, true); - - // Assert the function did NOT change all ancestors in the chain - assertEquals(node.async, false); - - // Assert it couldn't find a function identifier - assertEquals(state.functionIdentifiers.size, 0); - }); -}); - -describe('checkReassignmentofModifiedIdentifiers', () => { - it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBar.code}"`, () => { - const node = structuredClone(AssignmentOfFooToBar.node); - const ancestors: AnyNode[] = [ - node, // ExpressionStatement - node.expression, // AssignmentExpression - (node.expression as AssignmentExpression).right, // Identifier - ]; - const state: WalkerState = { - isModified: true, - functionIdentifiers: new Set(['foo']), - }; - - checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); - - assertEquals(state.functionIdentifiers.has('bar'), true); - }); - - it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarMemberExpression.code}"`, () => { - const node = structuredClone(AssignmentOfFooToBarMemberExpression.node); - const ancestors: AnyNode[] = [ - node, // ExpressionStatement - node.expression, // AssignmentExpression - (node.expression as AssignmentExpression).right, // Identifier - ]; - const state: WalkerState = { - isModified: true, - functionIdentifiers: new Set(['foo']), - }; - - checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); - - assertEquals(state.functionIdentifiers.has('bar'), true); - }); - - it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarVariableDeclarator.code}"`, () => { - const node = structuredClone(AssignmentOfFooToBarVariableDeclarator.node); - const ancestors: AnyNode[] = [ - node, // VariableDeclaration - node.declarations[0], // VariableDeclarator - ]; - const state: WalkerState = { - isModified: true, - functionIdentifiers: new Set(['foo']), - }; - - checkReassignmentOfModifiedIdentifiers(node.declarations[0], state, ancestors, ''); - - assertEquals(state.functionIdentifiers.has('bar'), true); - }); - - it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarPropertyDefinition.code}"`, () => { - const node = structuredClone(AssignmentOfFooToBarPropertyDefinition.node); - const ancestors: AnyNode[] = [ - node, // ClassDeclaration - node.body, // ClassBody - node.body.body[0], // PropertyDefinition - ]; - const state: WalkerState = { - isModified: true, - functionIdentifiers: new Set(['foo']), - }; - - checkReassignmentOfModifiedIdentifiers(node.body.body[0], state, ancestors, ''); - - assertEquals(state.functionIdentifiers.has('bar'), true); - }); -}); - -describe('buildFixModifiedFunctionsOperation', function () { - const state: WalkerState = { - isModified: false, - functionIdentifiers: new Set(['foo']), - }; - - const fixFunction = buildFixModifiedFunctionsOperation(state.functionIdentifiers); - - beforeEach(() => { - state.isModified = false; - state.functionIdentifiers = new Set(['foo']); - }); - - it(`fixes calls of "foo" in the code "${FixSimpleCallExpression.code}"`, () => { - const node = structuredClone(FixSimpleCallExpression.node); - const ancestors: AnyNode[] = [ - node, // FunctionDeclaration - node.body, // BlockStatement - node.body.body[0], // VariableDeclaration - (node.body.body[0] as VariableDeclaration).declarations[0], // VariableDeclarator - (node.body.body[0] as VariableDeclaration).declarations[0].init!, // CallExpression - ]; - - fixFunction(ancestors[4], state, ancestors, ''); - - assertEquals(state.isModified, true); - assertEquals(state.functionIdentifiers.has('bar'), true); - assertNotEquals(FixSimpleCallExpression.node, node); - assertEquals(node.async, true); - assertEquals(ancestors[4].type, 'AwaitExpression'); - }); - - it(`fixes calls of "foo" in the code "${ArrowFunctionDerefCallExpression.code}"`, () => { - const node = structuredClone(ArrowFunctionDerefCallExpression.node); - const ancestors: AnyNode[] = [ - node, // VariableDeclaration - node.declarations[0], // VariableDeclarator - node.declarations[0].init!, // ArrowFunctionExpression - (node.declarations[0].init as ArrowFunctionExpression).body, // CallExpression - ]; - - fixFunction(ancestors[3], state, ancestors, ''); - - // Recorded that a modification has been made - assertEquals(state.isModified, true); - // Recorded that the enclosing scope of the call also requires fixing - assertEquals(state.functionIdentifiers.has('bar'), true); - // Original node and fixed node are different - assertNotEquals(ArrowFunctionDerefCallExpression.node, node); - // The function call is now await'ed - assertEquals(ancestors[3].type, 'AwaitExpression'); - // The parent function of the call is now marked as async - assertEquals((ancestors[2] as ArrowFunctionExpression).async, true); - }); -}); diff --git a/packages/apps/deno-runtime/lib/codec.ts b/packages/apps/deno-runtime/lib/codec.ts deleted file mode 100644 index 300ea2f60ff03..0000000000000 --- a/packages/apps/deno-runtime/lib/codec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Buffer } from 'node:buffer'; - -import { decode, Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; -import { App } from '@rocket.chat/apps-engine/definition/App'; - -import { applySecureFields, type WithSecureFields } from './secureFields'; - -const FUNCTION_DISABLER_EXT = 0; -const BUFFER_HANDLER_EXT = 1; -const SECURE_FIELDS_HANDLER_EXT = 2; - -const extensionCodec = new ExtensionCodec(); - -extensionCodec.register({ - type: FUNCTION_DISABLER_EXT, - encode: (object: unknown) => { - // We don't care about functions, but also don't want to throw an error - if (typeof object === 'function' || object instanceof App) { - return new Uint8Array(0); - } - - return null; - }, - decode: (_data: Uint8Array) => undefined, -}); - -// Since Deno doesn't have Buffer by default, we need to use Uint8Array -extensionCodec.register({ - type: BUFFER_HANDLER_EXT, - encode: (object: unknown) => { - if (object instanceof Buffer) { - return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); - } - - return null; - }, - // msgpack will reuse the Uint8Array instance, so WE NEED to copy it instead of simply creating a view - decode: (data: Uint8Array) => { - return Buffer.from(data); - }, -}); - -extensionCodec.register({ - type: SECURE_FIELDS_HANDLER_EXT, - encode: (_object: unknown) => null, - decode: (data: Uint8Array) => applySecureFields(decode(data, { extensionCodec }) as WithSecureFields>), -}); - -export const encoder = new Encoder({ extensionCodec }); -export const decoder = new Decoder({ extensionCodec }); diff --git a/packages/apps/deno-runtime/lib/logger.ts b/packages/apps/deno-runtime/lib/logger.ts deleted file mode 100644 index b7d6ba1c2a705..0000000000000 --- a/packages/apps/deno-runtime/lib/logger.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { ILogEntry } from '@rocket.chat/apps-engine/definition/accessors/ILogEntry'; -import type { ILogger } from '@rocket.chat/apps-engine/definition/accessors/ILogger'; -import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata/AppMethod'; -import stackTrace from 'stack-trace'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; - -export interface IStackFrame { - getTypeName(): string; - getFunctionName(): string; - getMethodName(): string; - getFileName(): string; - getLineNumber(): number; - getColumnNumber(): number; - isNative(): boolean; - isConstructor(): boolean; -} - -enum LogMessageSeverity { - DEBUG = 'debug', - INFORMATION = 'info', - LOG = 'log', - WARNING = 'warning', - ERROR = 'error', - SUCCESS = 'success', -} - -type Entry = { - caller: string; - severity: LogMessageSeverity; - method: string; - timestamp: Date; - args: Array; -}; - -interface ILoggerStorageEntry { - appId: string; - method: string; - entries: Array; - startTime: Date; - endTime: Date; - totalTime: number; - _createdAt: Date; -} - -export class Logger implements ILogger { - public method: `${AppMethod}`; - - private entries: Array; - - private start: Date; - - constructor(method: string) { - this.method = method as `${AppMethod}`; - this.entries = []; - this.start = new Date(); - } - - public debug(...args: Array): void { - this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args); - } - - public info(...args: Array): void { - this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args); - } - - public log(...args: Array): void { - this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args); - } - - public warn(...args: Array): void { - this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args); - } - - public error(...args: Array): void { - this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args); - } - - public success(...args: Array): void { - this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args); - } - - private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { - const i = items.map((args) => { - if (args instanceof Error) { - return JSON.stringify(args, Object.getOwnPropertyNames(args)); - } - if (typeof args === 'object' && args !== null && 'stack' in args) { - return JSON.stringify(args, Object.getOwnPropertyNames(args)); - } - if (typeof args === 'object' && args !== null && 'message' in args) { - return JSON.stringify(args, Object.getOwnPropertyNames(args)); - } - const str = JSON.stringify(args, null, 2); - return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references - }); - - this.entries.push({ - caller, - severity, - method: this.method, - timestamp: new Date(), - args: i, - }); - } - - private getStack(stack: Array): string { - let func = 'anonymous'; - - if (stack.length === 1) { - return func; - } - - const frame = stack[1]; - - if (frame.getMethodName() === null) { - func = 'anonymous OR constructor'; - } else { - func = frame.getMethodName(); - } - - if (frame.getFunctionName() !== null) { - func = `${func} -> ${frame.getFunctionName()}`; - } - - return func; - } - - public getTotalTime(): number { - return new Date().getTime() - this.start.getTime(); - } - - public getEntries(): Array { - return this.entries as Array; - } - - public getMethod(): `${AppMethod}` { - return this.method; - } - - public getStartTime(): Date { - return this.start; - } - - public getEndTime(): Date { - return new Date(); - } - - public hasEntries(): boolean { - return this.entries.length > 0; - } - - public getLogs(): ILoggerStorageEntry { - return { - appId: AppObjectRegistry.get('id')!, - method: this.method, - entries: this.entries, - startTime: this.start, - endTime: new Date(), - totalTime: this.getTotalTime(), - _createdAt: new Date(), - }; - } -} diff --git a/packages/apps/deno-runtime/lib/messenger.ts b/packages/apps/deno-runtime/lib/messenger.ts deleted file mode 100644 index 7998f1b654a92..0000000000000 --- a/packages/apps/deno-runtime/lib/messenger.ts +++ /dev/null @@ -1,200 +0,0 @@ -import EventEmitter from 'node:events'; - -import * as jsonrpc from 'jsonrpc-lite'; - -import { encoder } from './codec'; -import { RequestContext } from './requestContext'; - -export type RequestDescriptor = Pick; - -export type NotificationDescriptor = Pick; - -export type SuccessResponseDescriptor = Pick; - -export type ErrorResponseDescriptor = Pick; - -export type JsonRpcRequest = jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification; -export type JsonRpcResponse = jsonrpc.IParsedObjectSuccess | jsonrpc.IParsedObjectError; - -export function isRequest(message: jsonrpc.IParsedObject): message is JsonRpcRequest { - return message.type === 'request' || message.type === 'notification'; -} - -export function isResponse(message: jsonrpc.IParsedObject): message is JsonRpcResponse { - return message.type === 'success' || message.type === 'error'; -} - -export function isErrorResponse(message: jsonrpc.JsonRpc): message is jsonrpc.ErrorObject { - return message instanceof jsonrpc.ErrorObject; -} - -const COMMAND_PONG = '_zPONG'; - -export const RPCResponseObserver = new EventEmitter(); - -export const Queue = new (class Queue { - private queue: Uint8Array[] = []; - private isProcessing = false; - - private async processQueue() { - if (this.isProcessing) { - return; - } - - this.isProcessing = true; - - while (this.queue.length) { - const message = this.queue.shift(); - - if (message) { - await transport.send(message); - } - } - - this.isProcessing = false; - } - - public enqueue(message: jsonrpc.JsonRpc | typeof COMMAND_PONG) { - this.queue.push(encoder.encode(message)); - this.processQueue(); - } - - public getCurrentSize() { - return this.queue.length; - } -})(); - -/** - * A platform-dependent component responsible for delivering encoded messages to - * the host that controls this runtime. - * - * Each runtime platform is expected to provide its own implementation and - * inject it via {@link setTransport}. - */ -export type Transport = { - send(message: Uint8Array): Promise; -}; - -/** - * The default transport. It discards every message, and is used until a - * platform injects its own transport via {@link setTransport}. - */ -export const noopTransport: Transport = { - send: () => Promise.resolve(), -}; - -let transport: Transport = noopTransport; - -/** - * Injects the transport implementation to be used when sending messages. - * - * Platforms must call this during bootstrap to wire up the appropriate - * transport. Until then, messages are discarded by the default no-op transport. - */ -export function setTransport(newTransport: Transport): void { - transport = newTransport; -} - -export function parseMessage(message: string | Record) { - let parsed: jsonrpc.IParsedObject | jsonrpc.IParsedObject[]; - - if (typeof message === 'string') { - parsed = jsonrpc.parse(message); - } else { - parsed = jsonrpc.parseObject(message); - } - - if (Array.isArray(parsed)) { - throw jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); - } - - if (parsed.type === 'invalid') { - throw jsonrpc.error(null, parsed.payload); - } - - return parsed; -} - -export async function sendInvalidRequestError(): Promise { - const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); - - await Queue.enqueue(rpc); -} - -export async function sendInvalidParamsError(id: jsonrpc.ID): Promise { - const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.invalidParams(null)); - - await Queue.enqueue(rpc); -} - -export async function sendParseError(): Promise { - const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.parseError(null)); - - await Queue.enqueue(rpc); -} - -export async function sendMethodNotFound(id: jsonrpc.ID): Promise { - const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.methodNotFound(null)); - - await Queue.enqueue(rpc); -} - -export async function errorResponse({ error: { message, code = -32000, data = {} }, id }: ErrorResponseDescriptor, req?: RequestContext): Promise { - const { logger } = req?.context || {}; - - if (logger?.hasEntries()) { - data.logs = logger.getLogs(); - } - - const rpc = jsonrpc.error(id, new jsonrpc.JsonRpcError(message, code, data)); - - await Queue.enqueue(rpc); -} - -export async function successResponse({ id, result }: SuccessResponseDescriptor, req: RequestContext): Promise { - const payload = { value: result } as Record; - const { logger } = req.context; - - if (logger.hasEntries()) { - payload.logs = logger.getLogs(); - } - - const rpc = jsonrpc.success(id, payload); - - await Queue.enqueue(rpc); -} - -export function pongResponse(): Promise { - return Promise.resolve(Queue.enqueue(COMMAND_PONG)); -} - -export async function sendRequest(requestDescriptor: RequestDescriptor): Promise { - const request = jsonrpc.request(Math.random().toString(36).slice(2), requestDescriptor.method, requestDescriptor.params); - - // TODO: add timeout to this - const responsePromise = new Promise((resolve, reject) => { - const handler = (payload: { error: Error } | { detail: jsonrpc.SuccessObject }) => { - if ('error' in payload) { - return reject(payload.error); - } - - return resolve(payload.detail); - }; - - RPCResponseObserver.once(`response:${request.id}`, handler); - }); - - await Queue.enqueue(request); - - return responsePromise as Promise; -} - -export function sendNotification({ method, params }: NotificationDescriptor) { - const request = jsonrpc.notification(method, params); - - Queue.enqueue(request); -} - -export function log(params: jsonrpc.RpcParams) { - sendNotification({ method: 'log', params }); -} diff --git a/packages/apps/deno-runtime/lib/metricsCollector.ts b/packages/apps/deno-runtime/lib/metricsCollector.ts deleted file mode 100644 index 9737c9e8e4eaf..0000000000000 --- a/packages/apps/deno-runtime/lib/metricsCollector.ts +++ /dev/null @@ -1,23 +0,0 @@ -import process from 'node:process'; - -import { Queue } from './messenger'; - -export function collectMetrics() { - return { - pid: process.pid, - queueSize: Queue.getCurrentSize(), - }; -} - -const encoder = new TextEncoder(); - -/** - * Sends metrics collected from the system via stderr - */ -export async function sendMetrics() { - const metrics = collectMetrics(); - - await new Promise((resolve, reject) => { - process.stderr.write(encoder.encode(JSON.stringify(metrics)), (error) => (error ? reject(error) : resolve())); - }); -} diff --git a/packages/apps/deno-runtime/lib/parseArgs.ts b/packages/apps/deno-runtime/lib/parseArgs.ts deleted file mode 100644 index ec1efd80a9ff2..0000000000000 --- a/packages/apps/deno-runtime/lib/parseArgs.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { parseArgs as $parseArgs } from 'node:util'; - -export type ParsedArgs = { - subprocess: string; - spawnId: number; - metricsReportFrequencyInMs?: number; -}; - -export function parseArgs(args: string[]): ParsedArgs { - const { values } = $parseArgs({ - args, - options: { - subprocess: { type: 'string' }, - spawnId: { type: 'string' }, - metricsReportFrequencyInMs: { type: 'string' }, - }, - strict: false, - }); - - return { - subprocess: (values.subprocess as string) ?? '', - spawnId: Number(values.spawnId ?? 0), - metricsReportFrequencyInMs: - typeof values.metricsReportFrequencyInMs === 'string' && Number.isFinite(values.metricsReportFrequencyInMs) - ? Number(values.metricsReportFrequencyInMs) - : undefined, - }; -} diff --git a/packages/apps/deno-runtime/lib/requestContext.ts b/packages/apps/deno-runtime/lib/requestContext.ts deleted file mode 100644 index 28b36cdeacb3d..0000000000000 --- a/packages/apps/deno-runtime/lib/requestContext.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RequestObject } from 'jsonrpc-lite'; - -import { Logger } from './logger'; - -export type RequestContext = RequestObject & { - context: { - logger: Logger; - [key: string]: unknown; - } -}; diff --git a/packages/apps/deno-runtime/lib/require.ts b/packages/apps/deno-runtime/lib/require.ts index a2c17036237a5..fc11e6b1c7d9b 100644 --- a/packages/apps/deno-runtime/lib/require.ts +++ b/packages/apps/deno-runtime/lib/require.ts @@ -2,13 +2,43 @@ import { createRequire } from 'node:module'; const _require = createRequire(import.meta.url); -export const require = (mod: string) => { - // When we try to import something from the apps-engine, we resolve the path using import maps from Deno - // However, the import maps are configured to look at the source folder for typescript files, but during - // runtime those files are not available - if (mod.startsWith('@rocket.chat/apps-engine')) { - mod = import.meta.resolve(mod).replace('file://', ''); +const ALLOWED_NATIVE_MODULES = [ + 'path', + 'url', + 'crypto', + 'buffer', + 'stream', + 'net', + 'http', + 'https', + 'zlib', + 'util', + 'punycode', + 'os', + 'querystring', + 'fs', +]; + +const ALLOWED_EXTERNAL_MODULES = ['uuid', '@rocket.chat/apps-engine']; + +// As the apps are bundled, the only times they will call require are +// 1. To require native modules +// 2. To require external npm packages we may provide +// 3. To require apps-engine files +export const require = (module: string) => { + // Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto' + const normalized = module.replace('node:', ''); + + + // We allow variants like 'fs', 'node:fs' or 'node:fs/promises' + if (ALLOWED_NATIVE_MODULES.some((mod) => normalized.startsWith(mod))) { + return _require(`node:${normalized}`); + } + + if (ALLOWED_EXTERNAL_MODULES.some((mod) => module.startsWith(mod))) { + // External modules cannot be resolved by the require algorithm, we need to pass the full path already resolved by Deno's import map + return _require(import.meta.resolve(module).replace('file://', '')); } - return _require(mod); + throw new Error(`Module ${module} is not allowed`); }; diff --git a/packages/apps/deno-runtime/lib/room.ts b/packages/apps/deno-runtime/lib/room.ts deleted file mode 100644 index f4bb0547d1731..0000000000000 --- a/packages/apps/deno-runtime/lib/room.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { IAbacAttributeDefinition } from '@rocket.chat/apps-engine/definition/abac/AbacAttributes'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; -import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; -import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; - -/** Minimal interface covering the only AppManager capability used by Room */ -interface IRoomManager { - getBridges(): { - getInternalBridge(): { - doGetUsernamesOfRoomById(id: string): Promise>; - }; - }; -} - -const PrivateManager = Symbol('RoomPrivateManager'); - -export class Room { - public id: string | undefined; - - public displayName?: string; - - public slugifiedName: string | undefined; - - public type: RoomType | undefined; - - public creator: IUser | undefined; - - public isDefault?: boolean; - - public isReadOnly?: boolean; - - public displaySystemMessages?: boolean; - - public messageCount?: number; - - public createdAt?: Date; - - public updatedAt?: Date; - - public lastModifiedAt?: Date; - - public customFields?: { [key: string]: unknown }; - - public userIds?: Array; - - public abacAttributes?: IAbacAttributeDefinition[]; - - private _USERNAMES: Promise> | undefined; - - private [PrivateManager]: IRoomManager | undefined; - - /** - * @deprecated - */ - public get usernames(): Promise> { - if (!this.id) return Promise.resolve([]); - - if (!this._USERNAMES) { - this._USERNAMES = this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); - } - - return this._USERNAMES || Promise.resolve([]); - } - - public set usernames(usernames) {} - - public constructor(room: IRoom, manager: IRoomManager) { - Object.assign(this, room); - - Object.defineProperty(this, PrivateManager, { - configurable: false, - enumerable: false, - writable: false, - value: manager, - }); - } - - get value(): object { - return { - id: this.id, - displayName: this.displayName, - slugifiedName: this.slugifiedName, - type: this.type, - creator: this.creator, - isDefault: this.isDefault, - isReadOnly: this.isReadOnly, - displaySystemMessages: this.displaySystemMessages, - messageCount: this.messageCount, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - lastModifiedAt: this.lastModifiedAt, - customFields: this.customFields, - userIds: this.userIds, - abacAttributes: this.abacAttributes, - }; - } - - public async getUsernames(): Promise> { - // Get usernames - if (!this._USERNAMES) { - this._USERNAMES = await this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); - } - - return this._USERNAMES || []; - } - - public toJSON() { - return this.value; - } - - public toString() { - return this.value; - } - - public valueOf() { - return this.value; - } -} diff --git a/packages/apps/deno-runtime/lib/roomFactory.ts b/packages/apps/deno-runtime/lib/roomFactory.ts deleted file mode 100644 index ba3490268a091..0000000000000 --- a/packages/apps/deno-runtime/lib/roomFactory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AppManager } from '@rocket.chat/apps/dist/server/AppManager'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; - -import { AppAccessors } from './accessors/mod'; -import { formatErrorResponse } from './accessors/formatResponseErrorHandler'; -import { Room } from './room'; - -const getMockAppManager = (senderFn: AppAccessors['senderFn']) => ({ - getBridges: () => ({ - getInternalBridge: () => ({ - doGetUsernamesOfRoomById: (roomId: string) => { - return senderFn({ - method: 'bridges:getInternalBridge:doGetUsernamesOfRoomById', - params: [roomId], - }) - .then((result) => result.result) - .catch((err) => { - throw formatErrorResponse(err); - }); - }, - }), - }), -}); - -export default function createRoom(room: IRoom, senderFn: AppAccessors['senderFn']): IRoom { - const mockAppManager = getMockAppManager(senderFn); - - return new Room(room, mockAppManager as unknown as AppManager) as unknown as IRoom; -} diff --git a/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts b/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts deleted file mode 100644 index aa2898418db81..0000000000000 --- a/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { fixBrokenSynchronousAPICalls } from './ast/mod'; - -function hasPotentialDeprecatedUsage(source: string) { - return ( - // potential usage of Room.usernames getter - source.includes('.usernames') || - // potential usage of LivechatRead.isOnline method - source.includes('.isOnline(') || - // potential usage of LivechatCreator.createToken method - source.includes('.createToken(') - ); -} - -export function sanitizeDeprecatedUsage(source: string) { - if (!hasPotentialDeprecatedUsage(source)) { - return source; - } - - return fixBrokenSynchronousAPICalls(source); -} diff --git a/packages/apps/deno-runtime/lib/secureFields.ts b/packages/apps/deno-runtime/lib/secureFields.ts deleted file mode 100644 index e028c824c5951..0000000000000 --- a/packages/apps/deno-runtime/lib/secureFields.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { kSecureFields, WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { AppObjectRegistry } from '../AppObjectRegistry'; - -export type { WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; - -export function applySecureFields(object: WithSecureFields>) { - const { [kSecureFields]: secureFields, ...rest } = object; - - const app = AppObjectRegistry.get('app'); - - if (!app) { - throw new Error("App unavailable, can't parse object with secure fields"); - } - - secureFields.forEach(({ permission, name, value }) => { - if (!app.getInfo().permissions?.find((p) => p.name === permission)) { - return; - } - - rest[name] = value; - }); - - return rest; -} diff --git a/packages/apps/deno-runtime/lib/tests/logger.test.ts b/packages/apps/deno-runtime/lib/tests/logger.test.ts deleted file mode 100644 index ee3da4bc8340d..0000000000000 --- a/packages/apps/deno-runtime/lib/tests/logger.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { Logger } from '../logger'; - -describe('Logger', () => { - it('getLogs should return an array of entries', () => { - const logger = new Logger('test'); - logger.info('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.method, 'test'); - }); - - it('should be able to add entries of different severity', () => { - const logger = new Logger('test'); - logger.info('test'); - logger.debug('test'); - logger.error('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 3); - assertEquals(logs.entries[0].severity, 'info'); - assertEquals(logs.entries[1].severity, 'debug'); - assertEquals(logs.entries[2].severity, 'error'); - }); - - it('should be able to add an info entry', () => { - const logger = new Logger('test'); - logger.info('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'info'); - }); - - it('should be able to add an debug entry', () => { - const logger = new Logger('test'); - logger.debug('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'debug'); - }); - - it('should be able to add an error entry', () => { - const logger = new Logger('test'); - logger.error('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'error'); - }); - - it('should be able to add an success entry', () => { - const logger = new Logger('test'); - logger.success('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'success'); - }); - - it('should be able to add an warning entry', () => { - const logger = new Logger('test'); - logger.warn('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'warning'); - }); - - it('should be able to add an log entry', () => { - const logger = new Logger('test'); - logger.log('test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'log'); - }); - - it('should be able to add an entry with multiple arguments', () => { - const logger = new Logger('test'); - logger.log('test', 'test', 'test'); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].args[1], 'test'); - assertEquals(logs.entries[0].args[2], 'test'); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'log'); - }); - - it('should be able to add an entry with multiple arguments of different types', () => { - const logger = new Logger('test'); - logger.log('test', 1, true, { foo: 'bar' }); - const logs = logger.getLogs(); - assertEquals(logs.entries.length, 1); - assertEquals(logs.entries[0].args[0], 'test'); - assertEquals(logs.entries[0].args[1], 1); - assertEquals(logs.entries[0].args[2], true); - assertEquals(logs.entries[0].args[3], { foo: 'bar' }); - assertEquals(logs.entries[0].method, 'test'); - assertEquals(logs.entries[0].severity, 'log'); - }); -}); diff --git a/packages/apps/deno-runtime/lib/tests/messenger.test.ts b/packages/apps/deno-runtime/lib/tests/messenger.test.ts deleted file mode 100644 index 9c6057726bca5..0000000000000 --- a/packages/apps/deno-runtime/lib/tests/messenger.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; -import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; - -import * as Messenger from '../messenger'; -import { stdoutTransport } from '../transports/stdoutTransport'; -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { createMockRequest } from '../../handlers/tests/helpers/mod'; -import { RequestContext } from '../requestContext'; -import { JsonRpc } from 'jsonrpc-lite'; - -describe('Messenger', () => { - let context: RequestContext; - - beforeEach(() => { - AppObjectRegistry.clear(); - AppObjectRegistry.set('id', 'test'); - Messenger.setTransport(Messenger.noopTransport); - - context = createMockRequest({ method: 'test', params: [] }); - }); - - afterAll(() => { - AppObjectRegistry.clear(); - Messenger.setTransport(stdoutTransport); - }); - - it('should add logs to success responses', async () => { - const theSpy = spy(Messenger.Queue, 'enqueue'); - const { logger } = context.context; - - logger.info('test'); - - await Messenger.successResponse({ id: 'test', result: 'test' }, context); - - assertEquals(theSpy.calls.length, 1); - - const [responseArgument] = theSpy.calls[0].args; - - assertObjectMatch(responseArgument as JsonRpc, { - jsonrpc: '2.0', - id: 'test', - result: { - value: 'test', - logs: { - appId: 'test', - method: 'test', - entries: [ - { - severity: 'info', - method: 'test', - args: ['test'], - caller: 'anonymous OR constructor', - }, - ], - }, - }, - }); - - theSpy.restore(); - }); - - it('should add logs to error responses', async () => { - const theSpy = spy(Messenger.Queue, 'enqueue'); - const { logger } = context.context; - - logger.info('test'); - - await Messenger.errorResponse({ id: 'test', error: { code: -32000, message: 'test' } }, context); - - assertEquals(theSpy.calls.length, 1); - - const [responseArgument] = theSpy.calls[0].args; - - assertObjectMatch(responseArgument as JsonRpc, { - jsonrpc: '2.0', - id: 'test', - error: { - code: -32000, - message: 'test', - data: { - logs: { - appId: 'test', - method: 'test', - entries: [ - { - severity: 'info', - method: 'test', - args: ['test'], - caller: 'anonymous OR constructor', - }, - ], - }, - }, - }, - }); - - theSpy.restore(); - }); -}); diff --git a/packages/apps/deno-runtime/lib/tests/secureFields.test.ts b/packages/apps/deno-runtime/lib/tests/secureFields.test.ts deleted file mode 100644 index d6d780c0fffaf..0000000000000 --- a/packages/apps/deno-runtime/lib/tests/secureFields.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { assertEquals, assertThrows } from 'https://deno.land/std@0.203.0/assert/mod.ts'; -import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; - -import { AppObjectRegistry } from '../../AppObjectRegistry'; -import { applySecureFields } from '../secureFields'; - -const SECURE_FIELDS_KEY = '@@SecureFields'; - -describe('applySecureFields', () => { - beforeEach(() => { - AppObjectRegistry.clear(); - }); - - it('throws when app is unavailable', () => { - assertThrows( - () => applySecureFields({ foo: 'bar', [SECURE_FIELDS_KEY]: [] }), - Error, - "App unavailable, can't parse object with secure fields", - ); - }); - - it('applies only secure fields with matching permissions', () => { - AppObjectRegistry.set('app', { - getInfo: () => ({ - permissions: [{ name: 'abac.read' }], - }), - }); - - const parsed = applySecureFields({ - foo: 'bar', - [SECURE_FIELDS_KEY]: [ - { permission: 'abac.read', name: 'abacAttributes', value: { department: 'support' } }, - { permission: 'api.read', name: 'apiToken', value: 'secret' }, - ], - }); - - assertEquals(parsed, { - foo: 'bar', - abacAttributes: { department: 'support' }, - }); - }); - - it('overwrites an existing field when permission is granted', () => { - AppObjectRegistry.set('app', { - getInfo: () => ({ - permissions: [{ name: 'abac.read' }], - }), - }); - - const parsed = applySecureFields({ - abacAttributes: null, - [SECURE_FIELDS_KEY]: [{ permission: 'abac.read', name: 'abacAttributes', value: { tenant: 'alpha' } }], - }); - - assertEquals(parsed, { - abacAttributes: { tenant: 'alpha' }, - }); - }); -}); diff --git a/packages/apps/deno-runtime/lib/transports/stdoutTransport.ts b/packages/apps/deno-runtime/lib/transports/stdoutTransport.ts index dce9283b6fa1b..b2295605ab9c5 100644 --- a/packages/apps/deno-runtime/lib/transports/stdoutTransport.ts +++ b/packages/apps/deno-runtime/lib/transports/stdoutTransport.ts @@ -1,6 +1,6 @@ import { writeAll } from '@std/io'; -import type { Transport } from '../messenger'; +import type { Transport } from '@rocket.chat/apps/base-runtime/lib/messenger'; /** * Transport that writes messages to the process' standard output. diff --git a/packages/apps/deno-runtime/lib/wrapAppForRequest.ts b/packages/apps/deno-runtime/lib/wrapAppForRequest.ts deleted file mode 100644 index 33ea2d8cdb4dd..0000000000000 --- a/packages/apps/deno-runtime/lib/wrapAppForRequest.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { App } from '@rocket.chat/apps-engine/definition/App'; - -import { RequestContext } from './requestContext'; -import { isApp, isRecord } from '../handlers/lib/assertions'; - -export function wrapAppForRequest(app: App, req: RequestContext): App { - return new Proxy(app, { - get(target, property, receiver) { - if (property === 'logger') { - return req.context.logger; - } - - return Reflect.get(target, property, receiver); - }, - }); -} - -// Instances of objects that have a reference to an App instance won't change throughout the -// lifetime of the runtime, so we can cache the results to avoid iterating the same object multiple times -const composedCache = new WeakMap, ReturnType>(); - -function findAppProperty(v: NonNullable): [string, App] | undefined { - const cachedEntry = composedCache.get(v); - - if (cachedEntry) { - return cachedEntry; - } - - if (!isRecord(v)) { - // Enables us to avoid having to determine whether the value is a record again - composedCache.set(v, undefined); - return undefined; - } - - const entry = Object.entries(v).find(([_,v]) => isApp(v)) as [string, App] | undefined; - - composedCache.set(v, entry); - - return entry; -} - -export function wrapComposedApp>(composed: T, req: RequestContext): T { - const prop = findAppProperty(composed); - - if (!prop) { - return composed; - } - - const proxy = wrapAppForRequest(prop[1], req); - - return new Proxy(composed, { - get(target, property, receiver) { - if (property === prop[0]) { - return proxy; - } - - return Reflect.get(target, property, receiver); - }, - }) -} diff --git a/packages/apps/deno-runtime/main.ts b/packages/apps/deno-runtime/main.ts index 711f7abda07ad..4e68cc54f475d 100644 --- a/packages/apps/deno-runtime/main.ts +++ b/packages/apps/deno-runtime/main.ts @@ -7,13 +7,13 @@ import process from 'node:process'; // `@rocket.chat/apps/*` resolve to allowed paths. Importing the compiled `dist` // instead would run the base as CommonJS, whose `require()` bypasses the import // map and falls back to node_modules — outside the subprocess read allowlist. -import { stdoutTransport } from './lib/transports/stdoutTransport'; -import { setTransport } from './lib/messenger'; +import { setSandboxGlobals, setSandboxRequire } from '@rocket.chat/apps/base-runtime/handlers/app/construct'; +import { setTransport } from '@rocket.chat/apps/base-runtime/lib/messenger'; +import { startMainLoop } from '@rocket.chat/apps/base-runtime/mainLoop'; -import { setSandboxGlobals, setSandboxRequire } from './handlers/app/construct'; import registerErrorListeners from './error-handlers'; import { require } from './lib/require'; -import { startMainLoop } from './mainLoop'; +import { stdoutTransport } from './lib/transports/stdoutTransport'; if (!process.argv.includes('--subprocess')) { console.error(` @@ -46,9 +46,9 @@ setTransport(stdoutTransport); setSandboxRequire(require); setSandboxGlobals({ Buffer, Deno: undefined }); -registerErrorListeners(); - // Process-global side effect; doing it once at startup is cleaner than inside construct prepareEnvironment(); +registerErrorListeners(); + startMainLoop(); diff --git a/packages/apps/deno-runtime/mainLoop.ts b/packages/apps/deno-runtime/mainLoop.ts deleted file mode 100644 index d18fbd9ab1af6..0000000000000 --- a/packages/apps/deno-runtime/mainLoop.ts +++ /dev/null @@ -1,127 +0,0 @@ -import process from 'node:process'; - -import jsonrpc, { JsonRpcError, type SuccessObject } from 'jsonrpc-lite'; - -import apiHandler from './handlers/api-handler'; -import handleApp from './handlers/app/handler'; -import outboundMessageHandler from './handlers/outboundcomms-handler'; -import handleScheduler from './handlers/scheduler-handler'; -import slashcommandHandler from './handlers/slashcommand-handler'; -import videoConferenceHandler from './handlers/videoconference-handler'; -import { decoder } from './lib/codec'; -import { Logger } from './lib/logger'; -import * as Messenger from './lib/messenger'; -import { sendMetrics } from './lib/metricsCollector'; -import type { RequestContext } from './lib/requestContext'; - -type Handlers = { - app: typeof handleApp; - api: typeof apiHandler; - slashcommand: typeof slashcommandHandler; - videoconference: typeof videoConferenceHandler; - outboundCommunication: typeof outboundMessageHandler; - scheduler: typeof handleScheduler; - ping: (request: RequestContext) => Promise<'pong'>; -}; - -const COMMAND_PING = '_zPING'; - -async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promise { - const methodHandlers: Handlers = { - app: handleApp, - api: apiHandler, - slashcommand: slashcommandHandler, - videoconference: videoConferenceHandler, - outboundCommunication: outboundMessageHandler, - scheduler: handleScheduler, - ping: (_request) => Promise.resolve('pong'), - }; - - // We're not handling notifications at the moment - if (type === 'notification') { - return Messenger.sendInvalidRequestError(); - } - - const { id, method } = payload; - - const logger = new Logger(method); - - const context: RequestContext = Object.assign(payload, { - context: { logger }, - }); - - const [methodPrefix] = method.split(':') as [keyof Handlers]; - const handler = methodHandlers[methodPrefix]; - - if (!handler) { - return Messenger.errorResponse( - { - error: { message: 'Method not found', code: -32601 }, - id, - }, - context, - ); - } - - const result = await handler(context).catch((reason) => - JsonRpcError.internalError({ cause: reason instanceof Error ? reason.toString() : reason }), - ); - - if (result instanceof JsonRpcError) { - return Messenger.errorResponse({ id, error: result }, context); - } - - return Messenger.successResponse({ id, result }, context); -} - -function handleResponse(response: Messenger.JsonRpcResponse): void { - let payload: { error: Error } | { detail: SuccessObject }; - - if (Messenger.isErrorResponse(response.payload)) { - payload = { error: new Error(response.payload.error.message) }; - } else { - payload = { detail: response.payload }; - } - - Messenger.RPCResponseObserver.emit(`response:${response.payload.id}`, payload); -} - -/** - * The platform-agnostic message loop shared by every runtime. - * - * Adapters are expected to wire up their platform seams — transport, sandbox - * `require`/globals, error listeners — during bootstrap and only then invoke - * this loop. It reads messages from `process.stdin` (a `node:` API available on - * every supported platform) and dispatches them to the shared handlers. - */ -export async function startMainLoop(): Promise { - Messenger.sendNotification({ method: 'ready', params: [] }); - - for await (const message of decoder.decodeStream(process.stdin)) { - try { - // Process PING command first as it is not JSON RPC - if (message === COMMAND_PING) { - void Messenger.pongResponse(); - void sendMetrics(); - continue; - } - - const JSONRPCMessage = Messenger.parseMessage(message as Record); - - if (Messenger.isRequest(JSONRPCMessage)) { - void requestRouter(JSONRPCMessage); - continue; - } - - if (Messenger.isResponse(JSONRPCMessage)) { - handleResponse(JSONRPCMessage); - } - } catch (error) { - if (Messenger.isErrorResponse(error)) { - await Messenger.errorResponse(error); - } else { - await Messenger.sendParseError(); - } - } - } -} diff --git a/packages/apps/deno-runtime/tests/error-handlers.test.ts b/packages/apps/deno-runtime/tests/error-handlers.test.ts index c9301ae7e726e..6eb47dfc75fc3 100644 --- a/packages/apps/deno-runtime/tests/error-handlers.test.ts +++ b/packages/apps/deno-runtime/tests/error-handlers.test.ts @@ -2,7 +2,7 @@ import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; -import * as Messenger from '../lib/messenger'; +import * as Messenger from '@rocket.chat/apps/base-runtime/lib/messenger'; import { unhandledExceptionListener, unhandledRejectionListener } from '../error-handlers'; From dd8c041a84a11279d969151d24a90893eff0c572 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 2 Jul 2026 19:04:46 -0300 Subject: [PATCH 4/4] refactor(apps): extract DenoSubprocessController logic to BaseSubprocessController --- packages/apps/src/server/ProxiedApp.ts | 2 +- .../src/server/managers/AppListenerManager.ts | 2 +- .../server/managers/AppVideoConfProvider.ts | 2 +- .../base/BaseRuntimeSubprocessController.ts | 702 +++++++++++++++++ .../runtime/{deno => base}/LivenessManager.ts | 12 +- .../{deno => base}/ProcessMessenger.ts | 12 +- .../server/runtime/{deno => base}/bundler.ts | 0 .../server/runtime/{deno => base}/codec.ts | 0 .../runtime/deno/AppsEngineDenoRuntime.ts | 714 +----------------- .../runtime/deno/LivenessManager.test.ts | 10 +- .../runtime/deno/bundleLegacyApp.test.ts | 2 +- 11 files changed, 754 insertions(+), 704 deletions(-) create mode 100644 packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts rename packages/apps/src/server/runtime/{deno => base}/LivenessManager.ts (95%) rename packages/apps/src/server/runtime/{deno => base}/ProcessMessenger.ts (81%) rename packages/apps/src/server/runtime/{deno => base}/bundler.ts (100%) rename packages/apps/src/server/runtime/{deno => base}/codec.ts (100%) diff --git a/packages/apps/src/server/ProxiedApp.ts b/packages/apps/src/server/ProxiedApp.ts index 3da36624262f3..18db21848b4dd 100644 --- a/packages/apps/src/server/ProxiedApp.ts +++ b/packages/apps/src/server/ProxiedApp.ts @@ -12,7 +12,7 @@ import { AppConsole } from './logging'; import { AppLicenseValidationResult } from './marketplace/license'; import type { AppsEngineRuntime } from './runtime/AppsEngineRuntime'; import type { IRuntimeController } from './runtime/IRuntimeController'; -import { JSONRPC_METHOD_NOT_FOUND } from './runtime/deno/AppsEngineDenoRuntime'; +import { JSONRPC_METHOD_NOT_FOUND } from './runtime/base/BaseRuntimeSubprocessController'; import type { AppInstallationSource, IAppStorageItem } from './storage'; export class ProxiedApp { diff --git a/packages/apps/src/server/managers/AppListenerManager.ts b/packages/apps/src/server/managers/AppListenerManager.ts index 38061236a360f..37e9a0dc3fa7e 100644 --- a/packages/apps/src/server/managers/AppListenerManager.ts +++ b/packages/apps/src/server/managers/AppListenerManager.ts @@ -35,7 +35,7 @@ import type { AppAccessorManager } from './AppAccessorManager'; import type { AppManager } from '../AppManager'; import type { ProxiedApp } from '../ProxiedApp'; import { Utilities } from '../misc/Utilities'; -import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/deno/AppsEngineDenoRuntime'; +import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/base/BaseRuntimeSubprocessController'; export interface IListenerExecutor { [AppInterface.IPreMessageSentPrevent]: { diff --git a/packages/apps/src/server/managers/AppVideoConfProvider.ts b/packages/apps/src/server/managers/AppVideoConfProvider.ts index 13e263e796b24..f93196f68bfc5 100644 --- a/packages/apps/src/server/managers/AppVideoConfProvider.ts +++ b/packages/apps/src/server/managers/AppVideoConfProvider.ts @@ -11,7 +11,7 @@ import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/v import type { ProxiedApp } from '../ProxiedApp'; import type { AppAccessorManager } from './AppAccessorManager'; -import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/deno/AppsEngineDenoRuntime'; +import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/base/BaseRuntimeSubprocessController'; import type { AppLogStorage } from '../storage'; export class AppVideoConfProvider { diff --git a/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts b/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts new file mode 100644 index 0000000000000..31dd7364e8d2e --- /dev/null +++ b/packages/apps/src/server/runtime/base/BaseRuntimeSubprocessController.ts @@ -0,0 +1,702 @@ +import * as child_process from 'node:child_process'; +import * as path from 'node:path'; +import { type Readable, EventEmitter } from 'node:stream'; +import { inspect as utilInspect } from 'node:util'; + +import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import debugFactory from 'debug'; +import * as jsonrpc from 'jsonrpc-lite'; + +import { LivenessManager } from './LivenessManager'; +import { ProcessMessenger } from './ProcessMessenger'; +import { bundleLegacyApp } from './bundler'; +import { newDecoder } from './codec'; +import type { AppManager } from '../../AppManager'; +import type { AppBridges } from '../../bridges'; +import type { IParseAppPackageResult } from '../../compiler'; +import { AppConsole, type ILoggerStorageEntry } from '../../logging'; +import type { AppAccessorManager, AppApiManager } from '../../managers'; +import type { AppLogStorage, IAppStorageItem } from '../../storage'; +import type { IRuntimeController } from '../IRuntimeController'; + +const inspect = (value: unknown) => utilInspect(value, { depth: 10, compact: true, breakLength: Infinity }); + +export const ALLOWED_ACCESSOR_METHODS = [ + 'getConfigurationExtend', + 'getEnvironmentRead', + 'getEnvironmentWrite', + 'getConfigurationModify', + 'getReader', + 'getPersistence', + 'getHttp', + 'getModifier', +] as Array< + keyof Pick< + AppAccessorManager, + | 'getConfigurationExtend' + | 'getEnvironmentRead' + | 'getEnvironmentWrite' + | 'getConfigurationModify' + | 'getReader' + | 'getPersistence' + | 'getHttp' + | 'getModifier' + > +>; + +const COMMAND_PONG = '_zPONG'; + +export const JSONRPC_METHOD_NOT_FOUND = -32601; + +function getRuntimeTimeout() { + const defaultTimeout = 30000; + const envValue = isFinite(process.env.APPS_ENGINE_RUNTIME_TIMEOUT as any) + ? Number(process.env.APPS_ENGINE_RUNTIME_TIMEOUT) + : defaultTimeout; + + if (envValue < 0) { + console.log('Environment variable APPS_ENGINE_RUNTIME_TIMEOUT has a negative value, ignoring...'); + return defaultTimeout; + } + + return envValue; +} + +function isValidOrigin(accessor: string): accessor is (typeof ALLOWED_ACCESSOR_METHODS)[number] { + return ALLOWED_ACCESSOR_METHODS.includes(accessor as any); +} + +/** + * Resolves the absolute path to @rocket.chat/apps-engine's src/ directory. + * Uses require.resolve so it works regardless of the runtime environment + * (monorepo dev, Meteor bundle, standalone node_modules). + */ +export function getAppsEngineDir(): string { + return path.dirname(require.resolve('@rocket.chat/apps-engine/package.json')); +} + +type AbortFunction = (reason?: any) => void; + +/** + * Describes how to spawn a subprocess for a given platform runtime. + */ +export type ProcessConfiguration = { + command: string; + args: string[]; + options: child_process.SpawnOptions; +}; + +/** + * Holds the platform-agnostic logic for controlling an app subprocess: spawning, + * killing, restarting, liveness, and the full JSON-RPC message loop (accessor, + * bridge, result and error handling). + * + * The only platform-specific concern - how to actually launch the subprocess for + * a given runtime (Deno, Node, ...) - is delegated to {@link buildProcessConfiguration}, + * which specialized subclasses must implement. Any additional per-platform setup + * (config generation, symlinks, path resolution) should happen in the subclass + * constructor after calling `super()`. + */ +export abstract class BaseRuntimeSubprocessController extends EventEmitter implements IRuntimeController { + private process: child_process.ChildProcess | undefined; + + private state: 'uninitialized' | 'ready' | 'invalid' | 'restarting' | 'unknown' | 'stopped'; + + /** + * Incremental id that keeps track of how many times we've spawned a process for this app + */ + protected spawnId = 0; + + protected readonly debug: debug.Debugger; + + private readonly options = { + timeout: getRuntimeTimeout(), + }; + + private readonly accessors: AppAccessorManager; + + private readonly api: AppApiManager; + + private readonly logStorage: AppLogStorage; + + private readonly bridges: AppBridges; + + private readonly messenger: ProcessMessenger; + + private readonly livenessManager: LivenessManager; + + protected readonly tempFilePath: string; + + protected readonly appsEnginePath: string; + + constructor( + // Human-readable name of the platform runtime (e.g. 'deno', 'node'), used for logging + private readonly runtimeName: string, + manager: AppManager, + // We need to keep the appSource around in case the subprocess needs to be restarted + protected readonly appPackage: IParseAppPackageResult, + private readonly storageItem: IAppStorageItem, + ) { + super(); + + this.tempFilePath = manager.getTempFilePath(); + this.appsEnginePath = getAppsEngineDir(); + + this.debug = debugFactory(`appsEngine:runtime:${runtimeName}`).extend(appPackage.info.id); + this.messenger = new ProcessMessenger(); + this.livenessManager = new LivenessManager({ + controller: this, + messenger: this.messenger, + debug: this.debug, + }); + + this.state = 'uninitialized'; + + this.accessors = manager.getAccessorManager(); + this.api = manager.getApiManager(); + this.logStorage = manager.getLogStorage(); + this.bridges = manager.getBridges(); + } + + /** + * Builds the command, arguments and spawn options used to launch a subprocess + * for the concrete platform runtime. + * + * SECURITY: implementations must ensure they fully control the command, the + * arguments and the script that will be executed. + */ + protected abstract buildProcessConfiguration(): ProcessConfiguration; + + public spawnProcess(): void { + try { + const { command, args, options } = this.buildProcessConfiguration(); + + this.process = child_process.spawn(command, args, options); + this.messenger.setReceiver(this.process); + this.livenessManager.attach(this.process); + + this.debug('Started subprocess %d with options %s and env %s', this.process.pid, inspect(args), inspect(options)); + + this.setupListeners(); + } catch (e) { + this.state = 'invalid'; + console.error(`Failed to start ${this.runtimeName} subprocess for app ${this.getAppId()}`, e); + } + } + + /** + * Attempts to kill the process currently controlled by this controller + * + * @returns boolean - if a process has been killed or not + */ + public async killProcess(): Promise { + if (!this.process) { + this.debug('No child process reference'); + return false; + } + + let { killed } = this.process; + + // This field is not populated if the process is killed by the OS + if (killed) { + this.debug('App process was already killed'); + return killed; + } + + // What else should we do? + if (this.process.kill('SIGKILL')) { + // Let's wait until we get confirmation the process exited + await new Promise((r) => this.process.on('exit', r)); + killed = true; + } else { + this.debug('Tried killing the process but failed. Was it already dead?'); + killed = false; + } + + delete this.process; + this.messenger.clearReceiver(); + return killed; + } + + // Debug purposes, could be deleted later + emit(eventName: string | symbol, ...args: any[]): boolean { + const hadListeners = super.emit(eventName, ...args); + + if (!hadListeners) { + this.debug('Emitted but no one listened: ', eventName, args); + } + + return hadListeners; + } + + public getProcessState() { + return this.state; + } + + public async getStatus(): Promise { + // If the process has been terminated, we can't get the status + if (this.process?.exitCode !== null) { + return AppStatus.UNKNOWN; + } + + return this.sendRequest({ method: 'app:getStatus', params: [] }) as Promise; + } + + public async setupApp() { + this.debug('Setting up app subprocess'); + this.spawnProcess(); + + // If there is more than one file in the package, then it is a legacy app that has not been bundled + if (Object.keys(this.appPackage.files).length > 1) { + await bundleLegacyApp(this.appPackage); + } + + await this.waitUntilReady(); + + await this.sendRequest({ method: 'app:construct', params: [this.appPackage] }); + + this.emit('constructed'); + } + + public async stopApp() { + this.debug('Stopping app subprocess'); + + this.state = 'stopped'; + + await this.killProcess(); + } + + public async restartApp() { + this.debug('Restarting app subprocess'); + const logger = new AppConsole('runtime:restart'); + + logger.info({ msg: 'Starting restart procedure for app subprocess...', runtimeData: this.livenessManager.getRuntimeData() }); + + this.state = 'restarting'; + + try { + const pid = this.process?.pid; + + const hasKilled = await this.killProcess(); + + if (hasKilled) { + logger.debug({ msg: 'Process successfully terminated', pid }); + } else { + logger.warn({ msg: 'Could not terminate process. Maybe it was already dead?', pid }); + } + + await this.setupApp(); + logger.info({ msg: 'New subprocess successfully spawned', pid: this.process.pid }); + + // setupApp() changes the state to 'ready' - we'll need to workaround that for now + this.state = 'restarting'; + + await this.sendRequest({ method: 'app:initialize' }); + await this.sendRequest({ method: 'app:setStatus', params: [this.storageItem.status] }); + + if (AppStatusUtils.isEnabled(this.storageItem.status)) { + await this.sendRequest({ method: 'app:onEnable' }); + } + + this.state = 'ready'; + + logger.info('Successfully restarted app subprocess'); + } catch (e) { + logger.error({ msg: "Failed to restart app's subprocess", err: e }); + throw e; + } finally { + await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); + } + } + + public getAppId(): string { + return this.appPackage.info.id; + } + + public async sendRequest(message: Pick, options = this.options): Promise { + const id = String(Math.random().toString(36)).substring(2); + + const start = Date.now(); + + const request = jsonrpc.request(id, message.method, message.params); + + const { promise, abort } = this.waitForResponse(request, options); + + try { + this.debug('Sending message to subprocess %s', inspect(message)); + this.messenger.send(request); + } catch (e) { + abort(e); + } + + return promise.finally(() => { + this.debug('Request %s for method %s took %dms', id, message.method, Date.now() - start); + }); + } + + private waitUntilReady(): Promise { + if (this.state === 'ready') { + return; + } + + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + + const handler = () => { + clearTimeout(timeoutId); + resolve(); + }; + + timeoutId = setTimeout(() => { + this.off('ready', handler); + reject(new Error(`[${this.getAppId()}] Timeout: app process not ready`)); + }, this.options.timeout); + + this.once('ready', handler); + }); + } + + private waitForResponse(req: jsonrpc.RequestObject, options = this.options): { abort: AbortFunction; promise: Promise } { + const controller = new AbortController(); + const { abort, signal } = controller; + + return { + abort: abort.bind(controller), + promise: new Promise((resolve, reject) => { + const eventName = `result:${req.id}`; + + const responseCallback = (result: unknown, error: jsonrpc.IParsedObjectError['payload']['error'] | Error) => { + this.off(eventName, responseCallback); + clearTimeout(timeoutId); + + if (error) { + reject(error); + } + + resolve(result); + }; + + const timeoutId = setTimeout( + () => + responseCallback( + undefined, + new Error(`[${this.getAppId()}] Request "${req.id}" for method "${req.method}" timed out after ${options.timeout}ms`), + ), + options.timeout, + ); + + signal.onabort = () => + responseCallback(undefined, signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason))); + + this.once(eventName, responseCallback); + }), + }; + } + + private onReady(): void { + this.state = 'ready'; + } + + /** + * Listeners need to be setup every time the reference + * in `this.process` changes, i.e. every time the subprocess + * is restarted + */ + private setupListeners(): void { + if (!this.process) { + return; + } + + this.process.stderr.on('data', this.parseError.bind(this)); + this.process.on('error', (err) => { + this.state = 'invalid'; + console.error(`Failed to startup ${this.runtimeName} subprocess for app ${this.getAppId()}`, err); + }); + + this.process.once('exit', (code) => this.emit('processExit', code)); + + this.once('ready', this.onReady.bind(this)); + + void this.parseStdout(this.process.stdout); + } + + // Probable should extract this to a separate file + private async handleAccessorMessage({ payload: { method, id, params } }: jsonrpc.IParsedObjectRequest): Promise { + const accessorMethods = method.substring(9).split(':'); // First 9 characters are always 'accessor:' + + this.debug('Handling accessor message %s with params %s', inspect(accessorMethods), inspect(params)); + + const managerOrigin = accessorMethods.shift(); + const tailMethodName = accessorMethods.pop(); + + // If we're restarting the app, we can't register resources again, so we + // hijack requests for the `ConfigurationExtend` accessor and don't let them through + // This needs to be refactored ASAP + if (this.state === 'restarting' && managerOrigin === 'getConfigurationExtend') { + return jsonrpc.success(id, null); + } + + if (managerOrigin === 'api' && tailMethodName === 'listApis') { + const result = this.api.listApis(this.appPackage.info.id); + + return jsonrpc.success(id, result); + } + + /** + * At this point, the accessorMethods array will contain the path to the accessor from the origin (AppAccessorManager) + * The accessor is the one that contains the actual method the app wants to call + * + * Most of the times, it will take one step from origin to accessor + * For example, for the call AppAccessorManager.getEnvironmentRead().getServerSettings().getValueById() we'll have + * the following: + * + * ``` + * const managerOrigin = 'getEnvironmentRead' + * const tailMethod = 'getValueById' + * const accessorMethods = ['getServerSettings'] + * ``` + * + * But sometimes there can be more steps, like in the following example: + * AppAccessorManager.getReader().getEnvironmentReader().getEnvironmentVariables().getValueByName() + * In this case, we'll have: + * + * ``` + * const managerOrigin = 'getReader' + * const tailMethod = 'getValueByName' + * const accessorMethods = ['getEnvironmentReader', 'getEnvironmentVariables'] + * ``` + **/ + // Prevent app from trying to get properties from the manager that + // are not intended for public access + if (!isValidOrigin(managerOrigin)) { + throw new Error(`Invalid accessor namespace "${managerOrigin}"`); + } + + // Need to fix typing of return value + const getAccessorForOrigin = ( + accessorMethods: string[], + managerOrigin: (typeof ALLOWED_ACCESSOR_METHODS)[number], + accessorManager: AppAccessorManager, + ) => { + const origin = accessorManager[managerOrigin](this.appPackage.info.id); + + if (managerOrigin === 'getHttp' || managerOrigin === 'getPersistence') { + return origin; + } + + if (managerOrigin === 'getConfigurationExtend' || managerOrigin === 'getConfigurationModify') { + return origin[accessorMethods[0] as keyof typeof origin]; + } + + let accessor = origin; + + // Call all intermediary objects to "resolve" the accessor + accessorMethods.forEach((methodName) => { + const method = accessor[methodName as keyof typeof accessor] as unknown; + + if (typeof method !== 'function') { + throw new Error(`Invalid accessor method "${methodName}"`); + } + + accessor = method.apply(accessor); + }); + + return accessor; + }; + + const accessor = getAccessorForOrigin(accessorMethods, managerOrigin, this.accessors); + + const tailMethod = accessor[tailMethodName as keyof typeof accessor] as unknown; + + if (typeof tailMethod !== 'function') { + throw new Error(`Invalid accessor method "${tailMethodName}"`); + } + + const result = await tailMethod.apply(accessor, params); + + return jsonrpc.success(id, typeof result === 'undefined' ? null : result); + } + + private async handleBridgeMessage({ + payload: { method, id, params }, + }: jsonrpc.IParsedObjectRequest): Promise { + const [bridgeName, bridgeMethod] = method.substring(8).split(':'); + + this.debug('Handling bridge message %s().%s() with params %s', bridgeName, bridgeMethod, inspect(params)); + + const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; + + if (!bridgeMethod.startsWith('do') || typeof bridge !== 'function' || !Array.isArray(params)) { + throw new Error('Invalid bridge request'); + } + + const bridgeInstance = bridge.call(this.bridges); + + const methodRef = bridgeInstance[bridgeMethod as keyof typeof bridge] as unknown; + + if (typeof methodRef !== 'function') { + throw new Error('Invalid bridge request'); + } + + let result; + try { + result = await methodRef.apply( + bridgeInstance, + // Should the protocol expect the placeholder APP_ID value or should the Deno process send the actual appId? + // If we do not expect the APP_ID, the Deno process will be able to impersonate other apps, potentially + params.map((value: unknown) => (value === 'APP_ID' ? this.appPackage.info.id : value)), + ); + } catch (error) { + this.debug('Error executing bridge method %s().%s() %s', bridgeName, bridgeMethod, inspect(error.message)); + const jsonRpcError = new jsonrpc.JsonRpcError(error.message, -32000, error); + return jsonrpc.error(id, jsonRpcError); + } + + return jsonrpc.success(id, typeof result === 'undefined' ? null : result); + } + + private async handleIncomingMessage(message: jsonrpc.IParsedObjectNotification | jsonrpc.IParsedObjectRequest): Promise { + const { method } = message.payload; + + if (method.startsWith('accessor:')) { + let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; + + try { + result = await this.handleAccessorMessage(message as jsonrpc.IParsedObjectRequest); + } catch (e) { + result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); + } + + this.messenger.send(result); + + return; + } + + if (method.startsWith('bridges:')) { + let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; + + try { + result = await this.handleBridgeMessage(message as jsonrpc.IParsedObjectRequest); + } catch (e) { + result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); + } + + this.messenger.send(result); + + return; + } + + switch (method) { + case 'ready': + this.emit('ready'); + break; + case 'log': + console.log('SUBPROCESS LOG', message); + break; + case 'unhandledRejection': + case 'uncaughtException': + await this.logUnhandledError(`runtime:${method}`, message); + break; + default: + console.warn('Unrecognized method from sub process'); + break; + } + } + + private async logUnhandledError( + method: `${AppMethod.RUNTIME_UNCAUGHT_EXCEPTION | AppMethod.RUNTIME_UNHANDLED_REJECTION}`, + message: jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification, + ) { + this.debug('Unhandled error of type "%s" caught in subprocess', method); + + const logger = new AppConsole(method); + logger.error(message.payload); + + await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); + } + + private async handleResultMessage(message: jsonrpc.IParsedObjectError | jsonrpc.IParsedObjectSuccess): Promise { + const { id } = message.payload; + + let result: unknown; + let error: jsonrpc.IParsedObjectError['payload']['error'] | undefined; + let logs: ILoggerStorageEntry; + + if (message.type === 'success') { + const params = message.payload.result as { value: unknown; logs?: ILoggerStorageEntry }; + result = params.value; + logs = params.logs; + } else { + error = message.payload.error; + logs = message.payload.error.data?.logs as ILoggerStorageEntry; + } + + // Should we try to make sure all result messages have logs? + if (logs) { + await this.logStorage.storeEntries(logs); + } + + this.emit(`result:${id}`, result, error); + } + + private async parseStdout(stream: Readable): Promise { + try { + for await (const message of newDecoder().decodeStream(stream)) { + this.debug('Received message from subprocess %s', inspect(message)); + try { + // Process PONG resonse first as it is not JSON RPC + if (message === COMMAND_PONG) { + this.emit('pong'); + continue; + } + + const JSONRPCMessage = jsonrpc.parseObject(message); + + if (Array.isArray(JSONRPCMessage)) { + throw new Error('Invalid message format'); + } + + this.emit('heartbeat'); + + if (JSONRPCMessage.type === 'request' || JSONRPCMessage.type === 'notification') { + this.handleIncomingMessage(JSONRPCMessage).catch((reason) => + console.error(`[${this.getAppId()}] Error executing handler`, reason, message), + ); + continue; + } + + if (JSONRPCMessage.type === 'success' || JSONRPCMessage.type === 'error') { + this.handleResultMessage(JSONRPCMessage).catch((reason) => + console.error(`[${this.getAppId()}] Error executing handler`, reason, message), + ); + continue; + } + + console.error('Unrecognized message type', JSONRPCMessage); + } catch (e) { + // SyntaxError is thrown when the message is not a valid JSON + if (e instanceof SyntaxError) { + console.error(`[${this.getAppId()}] Failed to parse message`); + continue; + } + + console.error(`[${this.getAppId()}] Error executing handler`, e, message); + } + } + } catch (e) { + console.error(`[${this.getAppId()}]`, e); + this.emit('error', new Error('DECODE_ERROR')); + } + } + + private async parseError(chunk: Buffer): Promise { + try { + const data = JSON.parse(chunk.toString()); + + this.debug('Metrics received from subprocess (via stderr): %s', inspect(data)); + } catch { + console.error('Subprocess stderr', chunk.toString()); + } + } +} diff --git a/packages/apps/src/server/runtime/deno/LivenessManager.ts b/packages/apps/src/server/runtime/base/LivenessManager.ts similarity index 95% rename from packages/apps/src/server/runtime/deno/LivenessManager.ts rename to packages/apps/src/server/runtime/base/LivenessManager.ts index 7b3e9ac83c1a8..248fce2d3bc72 100644 --- a/packages/apps/src/server/runtime/deno/LivenessManager.ts +++ b/packages/apps/src/server/runtime/base/LivenessManager.ts @@ -1,7 +1,7 @@ import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:stream'; -import type { DenoRuntimeSubprocessController } from './AppsEngineDenoRuntime'; +import type { BaseRuntimeSubprocessController } from './BaseRuntimeSubprocessController'; import type { ProcessMessenger } from './ProcessMessenger'; export const COMMAND_PING = '_zPING'; @@ -15,11 +15,11 @@ const defaultOptions: LivenessManager['options'] = { }; /** - * Responsible for pinging the Deno subprocess and for restarting it + * Responsible for pinging the subprocess and for restarting it * if something doesn't look right */ export class LivenessManager { - private readonly controller: DenoRuntimeSubprocessController; + private readonly controller: BaseRuntimeSubprocessController; private readonly messenger: ProcessMessenger; @@ -62,7 +62,7 @@ export class LivenessManager { constructor( deps: { - controller: DenoRuntimeSubprocessController; + controller: BaseRuntimeSubprocessController; messenger: ProcessMessenger; debug: debug.Debugger; }, @@ -98,8 +98,8 @@ export class LivenessManager { }; } - public attach(deno: ChildProcess) { - this.subprocess = deno; + public attach(subprocess: ChildProcess) { + this.subprocess = subprocess; this.pingTimeoutConsecutiveCount = 0; diff --git a/packages/apps/src/server/runtime/deno/ProcessMessenger.ts b/packages/apps/src/server/runtime/base/ProcessMessenger.ts similarity index 81% rename from packages/apps/src/server/runtime/deno/ProcessMessenger.ts rename to packages/apps/src/server/runtime/base/ProcessMessenger.ts index b17affd8218d2..1e2177a588705 100644 --- a/packages/apps/src/server/runtime/deno/ProcessMessenger.ts +++ b/packages/apps/src/server/runtime/base/ProcessMessenger.ts @@ -9,7 +9,7 @@ import { newEncoder } from './codec'; type Message = JsonRpc | typeof COMMAND_PING; export class ProcessMessenger { - private deno: ChildProcess | undefined; + private process: ChildProcess | undefined; private encoder: Encoder | undefined; @@ -23,21 +23,21 @@ export class ProcessMessenger { this._sendStrategy(message); } - public setReceiver(deno: ChildProcess) { - this.deno = deno; + public setReceiver(process: ChildProcess) { + this.process = process; this.switchStrategy(); } public clearReceiver() { - delete this.deno; + delete this.process; delete this.encoder; this.switchStrategy(); } private switchStrategy() { - if (this.deno?.stdin?.writable) { + if (this.process?.stdin?.writable) { this._sendStrategy = this.strategySend.bind(this); // Get a clean encoder @@ -52,6 +52,6 @@ export class ProcessMessenger { } private strategySend(message: Message) { - this.deno.stdin.write(this.encoder.encode(message)); + this.process.stdin.write(this.encoder.encode(message)); } } diff --git a/packages/apps/src/server/runtime/deno/bundler.ts b/packages/apps/src/server/runtime/base/bundler.ts similarity index 100% rename from packages/apps/src/server/runtime/deno/bundler.ts rename to packages/apps/src/server/runtime/base/bundler.ts diff --git a/packages/apps/src/server/runtime/deno/codec.ts b/packages/apps/src/server/runtime/base/codec.ts similarity index 100% rename from packages/apps/src/server/runtime/deno/codec.ts rename to packages/apps/src/server/runtime/base/codec.ts diff --git a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts index acc483f69692c..430185a94ca77 100644 --- a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts +++ b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -1,53 +1,11 @@ -import * as child_process from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { type Readable, EventEmitter } from 'node:stream'; -import { inspect as utilInspect } from 'node:util'; -import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; -import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; -import debugFactory from 'debug'; -import * as jsonrpc from 'jsonrpc-lite'; - -import { LivenessManager } from './LivenessManager'; -import { ProcessMessenger } from './ProcessMessenger'; -import { bundleLegacyApp } from './bundler'; -import { newDecoder } from './codec'; +import type { DenoConfigurationFileSchema } from './typings'; import type { AppManager } from '../../AppManager'; -import type { AppBridges } from '../../bridges'; import type { IParseAppPackageResult } from '../../compiler'; -import { AppConsole, type ILoggerStorageEntry } from '../../logging'; -import type { AppAccessorManager, AppApiManager } from '../../managers'; -import type { AppLogStorage, IAppStorageItem } from '../../storage'; -import type { IRuntimeController } from '../IRuntimeController'; -import type { DenoConfigurationFileSchema } from './typings'; - -const baseDebug = debugFactory('appsEngine:runtime:deno'); - -const inspect = (value: unknown) => utilInspect(value, { depth: 10, compact: true, breakLength: Infinity }); - -export const ALLOWED_ACCESSOR_METHODS = [ - 'getConfigurationExtend', - 'getEnvironmentRead', - 'getEnvironmentWrite', - 'getConfigurationModify', - 'getReader', - 'getPersistence', - 'getHttp', - 'getModifier', -] as Array< - keyof Pick< - AppAccessorManager, - | 'getConfigurationExtend' - | 'getEnvironmentRead' - | 'getEnvironmentWrite' - | 'getConfigurationModify' - | 'getReader' - | 'getPersistence' - | 'getHttp' - | 'getModifier' - > ->; +import type { IAppStorageItem } from '../../storage'; +import { BaseRuntimeSubprocessController, type ProcessConfiguration } from '../base/BaseRuntimeSubprocessController'; // Trying to access environment variables in Deno throws an error where in vm2 it simply returned `undefined` // So here we define the allowed envvars to prevent the process (and the compatibility) from breaking @@ -55,41 +13,10 @@ export const ALLOWED_ENVIRONMENT_VARIABLES = [ 'NODE_EXTRA_CA_CERTS', // Accessed by the `https` node module ]; -const COMMAND_PONG = '_zPONG'; - -export const JSONRPC_METHOD_NOT_FOUND = -32601; - -function getRuntimeTimeout() { - const defaultTimeout = 30000; - const envValue = isFinite(process.env.APPS_ENGINE_RUNTIME_TIMEOUT as any) - ? Number(process.env.APPS_ENGINE_RUNTIME_TIMEOUT) - : defaultTimeout; - - if (envValue < 0) { - console.log('Environment variable APPS_ENGINE_RUNTIME_TIMEOUT has a negative value, ignoring...'); - return defaultTimeout; - } - - return envValue; -} - -function isValidOrigin(accessor: string): accessor is (typeof ALLOWED_ACCESSOR_METHODS)[number] { - return ALLOWED_ACCESSOR_METHODS.includes(accessor as any); -} - function getDenoConfigPath(): string { return require.resolve('../../../../deno-runtime/deno.jsonc'); } -/** - * Resolves the absolute path to @rocket.chat/apps-engine's src/ directory. - * Uses require.resolve so it works regardless of the runtime environment - * (monorepo dev, Meteor bundle, standalone node_modules). - */ -function getAppsEngineDir(): string { - return path.dirname(require.resolve('@rocket.chat/apps-engine/package.json')); -} - /** * Generates a runtime deno.jsonc at `/deno_runtime.jsonc` by reading * the static config and injecting the resolved absolute path for @@ -118,38 +45,7 @@ function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string, fs.writeFileSync(targetPath, JSON.stringify(runtimeConfig, null, '\t')); } -type AbortFunction = (reason?: any) => void; - -export class DenoRuntimeSubprocessController extends EventEmitter implements IRuntimeController { - private deno: child_process.ChildProcess | undefined; - - private state: 'uninitialized' | 'ready' | 'invalid' | 'restarting' | 'unknown' | 'stopped'; - - /** - * Incremental id that keeps track of how many times we've spawned a process for this app - */ - private spawnId = 0; - - private readonly debug: debug.Debugger; - - private readonly options = { - timeout: getRuntimeTimeout(), - }; - - private readonly accessors: AppAccessorManager; - - private readonly api: AppApiManager; - - private readonly logStorage: AppLogStorage; - - private readonly bridges: AppBridges; - - private readonly messenger: ProcessMessenger; - - private readonly livenessManager: LivenessManager; - - private readonly tempFilePath: string; - +export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessController { private readonly denoBin = 'deno'; private readonly denoRuntimePath: string; @@ -160,21 +56,12 @@ export class DenoRuntimeSubprocessController extends EventEmitter implements IRu private readonly denoDir: string; - private readonly appsEnginePath: string; - private readonly packagePath: string; - constructor( - manager: AppManager, - // We need to keep the appSource around in case the Deno process needs to be restarted - private readonly appPackage: IParseAppPackageResult, - private readonly storageItem: IAppStorageItem, - ) { - super(); + constructor(manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) { + super('deno', manager, appPackage, storageItem); - this.tempFilePath = manager.getTempFilePath(); this.denoConfigPath = getDenoConfigPath(); - this.appsEnginePath = getAppsEngineDir(); this.packagePath = path.join(path.dirname(this.denoConfigPath), '..'); this.denoDir = process.env.DENO_DIR ?? path.join(this.packagePath, '.deno-cache'); @@ -196,581 +83,42 @@ export class DenoRuntimeSubprocessController extends EventEmitter implements IRu // Generate a runtime config with the resolved absolute path for @rocket.chat/apps-engine/ generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath); - - this.debug = baseDebug.extend(appPackage.info.id); - this.messenger = new ProcessMessenger(); - this.livenessManager = new LivenessManager({ - controller: this, - messenger: this.messenger, - debug: this.debug, - }); - - this.state = 'uninitialized'; - - this.accessors = manager.getAccessorManager(); - this.api = manager.getApiManager(); - this.logStorage = manager.getLogStorage(); - this.bridges = manager.getBridges(); } - public spawnProcess(): void { - try { - const allowedDirs = [this.tempFilePath, this.denoDir, this.packagePath, this.appsEnginePath]; + protected buildProcessConfiguration(): ProcessConfiguration { + const allowedDirs = [this.tempFilePath, this.denoDir, this.packagePath, this.appsEnginePath]; - const options = [ - 'run', - '--cached-only', - `--config=${this.denoEphemeralConfigPath}`, - `--allow-read=${allowedDirs.join(',')}`, - `--allow-env=${ALLOWED_ENVIRONMENT_VARIABLES.join(',')}`, - this.denoRuntimePath, - '--subprocess', - this.appPackage.info.id, - '--spawnId', - String(this.spawnId++), - ]; + const args = [ + 'run', + '--cached-only', + `--config=${this.denoEphemeralConfigPath}`, + `--allow-read=${allowedDirs.join(',')}`, + `--allow-env=${ALLOWED_ENVIRONMENT_VARIABLES.join(',')}`, + this.denoRuntimePath, + '--subprocess', + this.appPackage.info.id, + '--spawnId', + String(this.spawnId++), + ]; - // If the app doesn't request any permissions, it gets the default set of permissions, which includes "networking" - // If the app requests specific permissions, we need to check whether it requests "networking" or not - if (this.appPackage.info.permissions?.findIndex((p) => p.name === 'networking') !== -1) { - options.splice(1, 0, '--allow-net'); - } + // If the app doesn't request any permissions, it gets the default set of permissions, which includes "networking" + // If the app requests specific permissions, we need to check whether it requests "networking" or not + if (this.appPackage.info.permissions?.findIndex((p) => p.name === 'networking') !== -1) { + args.splice(1, 0, '--allow-net'); + } - const environment = { + // SECURITY: We control the command, the arguments and the script that will be executed. + return { + command: this.denoBin, + args, + options: { env: { // We need to pass the PATH, otherwise the shell won't find the deno executable // But the runtime itself won't have access to the env var because of the parameters PATH: process.env.PATH, DENO_DIR: this.denoDir, }, - }; - - // SECURITY: We control the command, the arguments and the script that will be executed. - this.deno = child_process.spawn(this.denoBin, options, environment); - this.messenger.setReceiver(this.deno); - this.livenessManager.attach(this.deno); - - this.debug('Started subprocess %d with options %s and env %s', this.deno.pid, inspect(options), inspect(environment)); - - this.setupListeners(); - } catch (e) { - this.state = 'invalid'; - console.error(`Failed to start Deno subprocess for app ${this.getAppId()}`, e); - } - } - - /** - * Attempts to kill the process currently controlled by this.deno - * - * @returns boolean - if a process has been killed or not - */ - public async killProcess(): Promise { - if (!this.deno) { - this.debug('No child process reference'); - return false; - } - - let { killed } = this.deno; - - // This field is not populated if the process is killed by the OS - if (killed) { - this.debug('App process was already killed'); - return killed; - } - - // What else should we do? - if (this.deno.kill('SIGKILL')) { - // Let's wait until we get confirmation the process exited - await new Promise((r) => this.deno.on('exit', r)); - killed = true; - } else { - this.debug('Tried killing the process but failed. Was it already dead?'); - killed = false; - } - - delete this.deno; - this.messenger.clearReceiver(); - return killed; - } - - // Debug purposes, could be deleted later - emit(eventName: string | symbol, ...args: any[]): boolean { - const hadListeners = super.emit(eventName, ...args); - - if (!hadListeners) { - this.debug('Emitted but no one listened: ', eventName, args); - } - - return hadListeners; - } - - public getProcessState() { - return this.state; - } - - public async getStatus(): Promise { - // If the process has been terminated, we can't get the status - if (this.deno?.exitCode !== null) { - return AppStatus.UNKNOWN; - } - - return this.sendRequest({ method: 'app:getStatus', params: [] }) as Promise; - } - - public async setupApp() { - this.debug('Setting up app subprocess'); - this.spawnProcess(); - - // If there is more than one file in the package, then it is a legacy app that has not been bundled - if (Object.keys(this.appPackage.files).length > 1) { - await bundleLegacyApp(this.appPackage); - } - - await this.waitUntilReady(); - - await this.sendRequest({ method: 'app:construct', params: [this.appPackage] }); - - this.emit('constructed'); - } - - public async stopApp() { - this.debug('Stopping app subprocess'); - - this.state = 'stopped'; - - await this.killProcess(); - } - - public async restartApp() { - this.debug('Restarting app subprocess'); - const logger = new AppConsole('runtime:restart'); - - logger.info({ msg: 'Starting restart procedure for app subprocess...', runtimeData: this.livenessManager.getRuntimeData() }); - - this.state = 'restarting'; - - try { - const pid = this.deno?.pid; - - const hasKilled = await this.killProcess(); - - if (hasKilled) { - logger.debug({ msg: 'Process successfully terminated', pid }); - } else { - logger.warn({ msg: 'Could not terminate process. Maybe it was already dead?', pid }); - } - - await this.setupApp(); - logger.info({ msg: 'New subprocess successfully spawned', pid: this.deno.pid }); - - // setupApp() changes the state to 'ready' - we'll need to workaround that for now - this.state = 'restarting'; - - await this.sendRequest({ method: 'app:initialize' }); - await this.sendRequest({ method: 'app:setStatus', params: [this.storageItem.status] }); - - if (AppStatusUtils.isEnabled(this.storageItem.status)) { - await this.sendRequest({ method: 'app:onEnable' }); - } - - this.state = 'ready'; - - logger.info('Successfully restarted app subprocess'); - } catch (e) { - logger.error({ msg: "Failed to restart app's subprocess", err: e }); - throw e; - } finally { - await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); - } - } - - public getAppId(): string { - return this.appPackage.info.id; - } - - public async sendRequest(message: Pick, options = this.options): Promise { - const id = String(Math.random().toString(36)).substring(2); - - const start = Date.now(); - - const request = jsonrpc.request(id, message.method, message.params); - - const { promise, abort } = this.waitForResponse(request, options); - - try { - this.debug('Sending message to subprocess %s', inspect(message)); - this.messenger.send(request); - } catch (e) { - abort(e); - } - - return promise.finally(() => { - this.debug('Request %s for method %s took %dms', id, message.method, Date.now() - start); - }); - } - - private waitUntilReady(): Promise { - if (this.state === 'ready') { - return; - } - - return new Promise((resolve, reject) => { - let timeoutId: NodeJS.Timeout; - - const handler = () => { - clearTimeout(timeoutId); - resolve(); - }; - - timeoutId = setTimeout(() => { - this.off('ready', handler); - reject(new Error(`[${this.getAppId()}] Timeout: app process not ready`)); - }, this.options.timeout); - - this.once('ready', handler); - }); - } - - private waitForResponse(req: jsonrpc.RequestObject, options = this.options): { abort: AbortFunction; promise: Promise } { - const controller = new AbortController(); - const { abort, signal } = controller; - - return { - abort: abort.bind(controller), - promise: new Promise((resolve, reject) => { - const eventName = `result:${req.id}`; - - const responseCallback = (result: unknown, error: jsonrpc.IParsedObjectError['payload']['error'] | Error) => { - this.off(eventName, responseCallback); - clearTimeout(timeoutId); - - if (error) { - reject(error); - } - - resolve(result); - }; - - const timeoutId = setTimeout( - () => - responseCallback( - undefined, - new Error(`[${this.getAppId()}] Request "${req.id}" for method "${req.method}" timed out after ${options.timeout}ms`), - ), - options.timeout, - ); - - signal.onabort = () => - responseCallback(undefined, signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason))); - - this.once(eventName, responseCallback); - }), + }, }; } - - private onReady(): void { - this.state = 'ready'; - } - - /** - * Listeners need to be setup every time the reference - * in `this.deno` changes, i.e. every time the subprocess - * is restarted - */ - private setupListeners(): void { - if (!this.deno) { - return; - } - - this.deno.stderr.on('data', this.parseError.bind(this)); - this.deno.on('error', (err) => { - this.state = 'invalid'; - console.error(`Failed to startup Deno subprocess for app ${this.getAppId()}`, err); - }); - - this.deno.once('exit', (code) => this.emit('processExit', code)); - - this.once('ready', this.onReady.bind(this)); - - void this.parseStdout(this.deno.stdout); - } - - // Probable should extract this to a separate file - private async handleAccessorMessage({ payload: { method, id, params } }: jsonrpc.IParsedObjectRequest): Promise { - const accessorMethods = method.substring(9).split(':'); // First 9 characters are always 'accessor:' - - this.debug('Handling accessor message %s with params %s', inspect(accessorMethods), inspect(params)); - - const managerOrigin = accessorMethods.shift(); - const tailMethodName = accessorMethods.pop(); - - // If we're restarting the app, we can't register resources again, so we - // hijack requests for the `ConfigurationExtend` accessor and don't let them through - // This needs to be refactored ASAP - if (this.state === 'restarting' && managerOrigin === 'getConfigurationExtend') { - return jsonrpc.success(id, null); - } - - if (managerOrigin === 'api' && tailMethodName === 'listApis') { - const result = this.api.listApis(this.appPackage.info.id); - - return jsonrpc.success(id, result); - } - - /** - * At this point, the accessorMethods array will contain the path to the accessor from the origin (AppAccessorManager) - * The accessor is the one that contains the actual method the app wants to call - * - * Most of the times, it will take one step from origin to accessor - * For example, for the call AppAccessorManager.getEnvironmentRead().getServerSettings().getValueById() we'll have - * the following: - * - * ``` - * const managerOrigin = 'getEnvironmentRead' - * const tailMethod = 'getValueById' - * const accessorMethods = ['getServerSettings'] - * ``` - * - * But sometimes there can be more steps, like in the following example: - * AppAccessorManager.getReader().getEnvironmentReader().getEnvironmentVariables().getValueByName() - * In this case, we'll have: - * - * ``` - * const managerOrigin = 'getReader' - * const tailMethod = 'getValueByName' - * const accessorMethods = ['getEnvironmentReader', 'getEnvironmentVariables'] - * ``` - **/ - // Prevent app from trying to get properties from the manager that - // are not intended for public access - if (!isValidOrigin(managerOrigin)) { - throw new Error(`Invalid accessor namespace "${managerOrigin}"`); - } - - // Need to fix typing of return value - const getAccessorForOrigin = ( - accessorMethods: string[], - managerOrigin: (typeof ALLOWED_ACCESSOR_METHODS)[number], - accessorManager: AppAccessorManager, - ) => { - const origin = accessorManager[managerOrigin](this.appPackage.info.id); - - if (managerOrigin === 'getHttp' || managerOrigin === 'getPersistence') { - return origin; - } - - if (managerOrigin === 'getConfigurationExtend' || managerOrigin === 'getConfigurationModify') { - return origin[accessorMethods[0] as keyof typeof origin]; - } - - let accessor = origin; - - // Call all intermediary objects to "resolve" the accessor - accessorMethods.forEach((methodName) => { - const method = accessor[methodName as keyof typeof accessor] as unknown; - - if (typeof method !== 'function') { - throw new Error(`Invalid accessor method "${methodName}"`); - } - - accessor = method.apply(accessor); - }); - - return accessor; - }; - - const accessor = getAccessorForOrigin(accessorMethods, managerOrigin, this.accessors); - - const tailMethod = accessor[tailMethodName as keyof typeof accessor] as unknown; - - if (typeof tailMethod !== 'function') { - throw new Error(`Invalid accessor method "${tailMethodName}"`); - } - - const result = await tailMethod.apply(accessor, params); - - return jsonrpc.success(id, typeof result === 'undefined' ? null : result); - } - - private async handleBridgeMessage({ - payload: { method, id, params }, - }: jsonrpc.IParsedObjectRequest): Promise { - const [bridgeName, bridgeMethod] = method.substring(8).split(':'); - - this.debug('Handling bridge message %s().%s() with params %s', bridgeName, bridgeMethod, inspect(params)); - - const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; - - if (!bridgeMethod.startsWith('do') || typeof bridge !== 'function' || !Array.isArray(params)) { - throw new Error('Invalid bridge request'); - } - - const bridgeInstance = bridge.call(this.bridges); - - const methodRef = bridgeInstance[bridgeMethod as keyof typeof bridge] as unknown; - - if (typeof methodRef !== 'function') { - throw new Error('Invalid bridge request'); - } - - let result; - try { - result = await methodRef.apply( - bridgeInstance, - // Should the protocol expect the placeholder APP_ID value or should the Deno process send the actual appId? - // If we do not expect the APP_ID, the Deno process will be able to impersonate other apps, potentially - params.map((value: unknown) => (value === 'APP_ID' ? this.appPackage.info.id : value)), - ); - } catch (error) { - this.debug('Error executing bridge method %s().%s() %s', bridgeName, bridgeMethod, inspect(error.message)); - const jsonRpcError = new jsonrpc.JsonRpcError(error.message, -32000, error); - return jsonrpc.error(id, jsonRpcError); - } - - return jsonrpc.success(id, typeof result === 'undefined' ? null : result); - } - - private async handleIncomingMessage(message: jsonrpc.IParsedObjectNotification | jsonrpc.IParsedObjectRequest): Promise { - const { method } = message.payload; - - if (method.startsWith('accessor:')) { - let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; - - try { - result = await this.handleAccessorMessage(message as jsonrpc.IParsedObjectRequest); - } catch (e) { - result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); - } - - this.messenger.send(result); - - return; - } - - if (method.startsWith('bridges:')) { - let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; - - try { - result = await this.handleBridgeMessage(message as jsonrpc.IParsedObjectRequest); - } catch (e) { - result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); - } - - this.messenger.send(result); - - return; - } - - switch (method) { - case 'ready': - this.emit('ready'); - break; - case 'log': - console.log('SUBPROCESS LOG', message); - break; - case 'unhandledRejection': - case 'uncaughtException': - await this.logUnhandledError(`runtime:${method}`, message); - break; - default: - console.warn('Unrecognized method from sub process'); - break; - } - } - - private async logUnhandledError( - method: `${AppMethod.RUNTIME_UNCAUGHT_EXCEPTION | AppMethod.RUNTIME_UNHANDLED_REJECTION}`, - message: jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification, - ) { - this.debug('Unhandled error of type "%s" caught in subprocess', method); - - const logger = new AppConsole(method); - logger.error(message.payload); - - await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); - } - - private async handleResultMessage(message: jsonrpc.IParsedObjectError | jsonrpc.IParsedObjectSuccess): Promise { - const { id } = message.payload; - - let result: unknown; - let error: jsonrpc.IParsedObjectError['payload']['error'] | undefined; - let logs: ILoggerStorageEntry; - - if (message.type === 'success') { - const params = message.payload.result as { value: unknown; logs?: ILoggerStorageEntry }; - result = params.value; - logs = params.logs; - } else { - error = message.payload.error; - logs = message.payload.error.data?.logs as ILoggerStorageEntry; - } - - // Should we try to make sure all result messages have logs? - if (logs) { - await this.logStorage.storeEntries(logs); - } - - this.emit(`result:${id}`, result, error); - } - - private async parseStdout(stream: Readable): Promise { - try { - for await (const message of newDecoder().decodeStream(stream)) { - this.debug('Received message from subprocess %s', inspect(message)); - try { - // Process PONG resonse first as it is not JSON RPC - if (message === COMMAND_PONG) { - this.emit('pong'); - continue; - } - - const JSONRPCMessage = jsonrpc.parseObject(message); - - if (Array.isArray(JSONRPCMessage)) { - throw new Error('Invalid message format'); - } - - this.emit('heartbeat'); - - if (JSONRPCMessage.type === 'request' || JSONRPCMessage.type === 'notification') { - this.handleIncomingMessage(JSONRPCMessage).catch((reason) => - console.error(`[${this.getAppId()}] Error executing handler`, reason, message), - ); - continue; - } - - if (JSONRPCMessage.type === 'success' || JSONRPCMessage.type === 'error') { - this.handleResultMessage(JSONRPCMessage).catch((reason) => - console.error(`[${this.getAppId()}] Error executing handler`, reason, message), - ); - continue; - } - - console.error('Unrecognized message type', JSONRPCMessage); - } catch (e) { - // SyntaxError is thrown when the message is not a valid JSON - if (e instanceof SyntaxError) { - console.error(`[${this.getAppId()}] Failed to parse message`); - continue; - } - - console.error(`[${this.getAppId()}] Error executing handler`, e, message); - } - } - } catch (e) { - console.error(`[${this.getAppId()}]`, e); - this.emit('error', new Error('DECODE_ERROR')); - } - } - - private async parseError(chunk: Buffer): Promise { - try { - const data = JSON.parse(chunk.toString()); - - this.debug('Metrics received from subprocess (via stderr): %s', inspect(data)); - } catch { - console.error('Subprocess stderr', chunk.toString()); - } - } } diff --git a/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts b/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts index 469aebe47ae0d..e093896f3ad1c 100644 --- a/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts +++ b/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts @@ -5,9 +5,9 @@ import { describe, it, beforeEach, afterEach, mock, type Mock } from 'node:test' import debugFactory from 'debug'; -import type { DenoRuntimeSubprocessController } from '../../../../src/server/runtime/deno/AppsEngineDenoRuntime'; -import { COMMAND_PING, LivenessManager } from '../../../../src/server/runtime/deno/LivenessManager'; -import { ProcessMessenger } from '../../../../src/server/runtime/deno/ProcessMessenger'; +import type { BaseRuntimeSubprocessController } from '../../../../src/server/runtime/base/BaseRuntimeSubprocessController'; +import { COMMAND_PING, LivenessManager } from '../../../../src/server/runtime/base/LivenessManager'; +import { ProcessMessenger } from '../../../../src/server/runtime/base/ProcessMessenger'; describe('LivenessManager ping mechanism', () => { const PING_INTERVAL_MS = 100; @@ -16,7 +16,7 @@ describe('LivenessManager ping mechanism', () => { const MAX_RESTARTS = Infinity; const RESTART_ATTEMPT_DELAY_MS = 10; - let mockController: DenoRuntimeSubprocessController; + let mockController: BaseRuntimeSubprocessController; let mockMessenger: ProcessMessenger; let mockSubprocess: ChildProcess; let debug: debug.Debugger; @@ -65,7 +65,7 @@ describe('LivenessManager ping mechanism', () => { prependListener: () => mockController, prependOnceListener: () => mockController, eventNames: (): string[] => [], - } as unknown as DenoRuntimeSubprocessController; + } as unknown as BaseRuntimeSubprocessController; // Mock subprocess mockSubprocess = { diff --git a/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts b/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts index cca6f7d80bcf7..fb370611df847 100644 --- a/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts +++ b/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import type { AppImplements } from '../../../../src/server/compiler/AppImplements'; import type { IParseAppPackageResult } from '../../../../src/server/compiler/IParseAppPackageResult'; -import { bundleLegacyApp } from '../../../../src/server/runtime/deno/bundler'; +import { bundleLegacyApp } from '../../../../src/server/runtime/base/bundler'; function makeAppPackage(classFile: string, files: { [key: string]: string }): IParseAppPackageResult { return {