Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor app construct handler #687

Merged
merged 3 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions deno-runtime/handlers/app/construct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { IParseAppPackageResult } from '@rocket.chat/apps-engine/server/compiler/IParseAppPackageResult.ts';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { require } from '../../lib/require.ts';
import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage.ts';
import { AppAccessorsInstance } from '../../lib/accessors/mod.ts';

const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring'];
const ALLOWED_EXTERNAL_MODULES = ['uuid'];

function buildRequire(): (module: string) => unknown {
return (module: string): unknown => {
if (ALLOWED_NATIVE_MODULES.includes(module)) {
return require(`node:${module}`);
}

if (ALLOWED_EXTERNAL_MODULES.includes(module)) {
return require(`npm:${module}`);
}

if (module.startsWith('@rocket.chat/apps-engine')) {
const path = module.concat('.js');
return require(path);
}

throw new Error(`Module ${module} is not allowed`);
};
}

function wrapAppCode(code: string): (require: (module: string) => unknown) => Promise<Record<string, unknown>> {
return new Function(
'require',
`
const exports = {};
const module = { exports };
const result = (async (exports,module,require,globalThis,Deno) => {
${code};
})(exports,module,require);
return result.then(() => module.exports);`,
) as (require: (module: string) => unknown) => Promise<Record<string, unknown>>;
}

export async function handlInitializeApp(params: unknown): Promise<boolean> {
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 logger = AppObjectRegistry.get('logger');

const app = new appClass(appPackage.info, 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;
}

28 changes: 28 additions & 0 deletions deno-runtime/handlers/app/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Defined, JsonRpcError } from "jsonrpc-lite";
import { handlInitializeApp } from "./construct.ts";

export default async function handleApp(method: string, params: unknown): Promise<Defined | JsonRpcError> {
const [, appMethod] = method.split(':');

let result: Defined;

try {
if (appMethod === 'construct') {
result = await handlInitializeApp(params);
} else {
result = null;
}
} 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);
}

return new JsonRpcError(e.message, -32000, e);
}

return result;
}
2 changes: 1 addition & 1 deletion deno-runtime/lib/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const Transport = new class Transporter {
}

private async stdoutTransport(message: jsonrpc.JsonRpc): Promise<void> {
const encoded = encoder.encode(message.serialize());
const encoded = encoder.encode(message.serialize() + AppObjectRegistry.get<string>('MESSAGE_SEPARATOR'));
await Deno.stdout.write(encoded);
}

Expand Down
102 changes: 10 additions & 92 deletions deno-runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,93 +8,16 @@ if (!Deno.args.includes('--subprocess')) {
Deno.exit(1001);
}

import { sanitizeDeprecatedUsage } from './lib/sanitizeDeprecatedUsage.ts';
import { AppAccessorsInstance } from './lib/accessors/mod.ts';
import { JsonRpcError } from 'jsonrpc-lite';

import * as Messenger from './lib/messenger.ts';
import { AppObjectRegistry } from './AppObjectRegistry.ts';
import { Logger } from './lib/logger.ts';
import { require } from './lib/require.ts';

import type { IParseAppPackageResult } from '@rocket.chat/apps-engine/server/compiler/IParseAppPackageResult.ts';
import slashcommandHandler from './handlers/slashcommand-handler.ts';
import { JsonRpcError } from "jsonrpc-lite";

const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring'];
const ALLOWED_EXTERNAL_MODULES = ['uuid'];

function buildRequire(): (module: string) => unknown {
return (module: string): unknown => {
if (ALLOWED_NATIVE_MODULES.includes(module)) {
return require(`node:${module}`);
}

if (ALLOWED_EXTERNAL_MODULES.includes(module)) {
return require(`npm:${module}`);
}

if (module.startsWith('@rocket.chat/apps-engine')) {
const path = module.replace('@rocket.chat/apps-engine/', new URL('..', import.meta.url).pathname).concat('.js');
return require(path);
}

throw new Error(`Module ${module} is not allowed`);
};
}

function wrapAppCode(code: string): (require: (module: string) => unknown) => Promise<Record<string, unknown>> {
return new Function(
'require',
`
const exports = {};
const module = { exports };
const result = (async (exports,module,require,globalThis,Deno) => {
${code};
})(exports,module,require);
return result.then(() => module.exports);`,
) as (require: (module: string) => unknown) => Promise<Record<string, unknown>>;
}

async function handlInitializeApp(appPackage: IParseAppPackageResult): Promise<void> {
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 logger = AppObjectRegistry.get('logger');

const app = new appClass(appPackage.info, logger, AppAccessorsInstance.getDefaultAppAccessors());

if (typeof app.getName !== 'function') {
throw new Error('App must contain a getName function');
}
import handleApp from './handlers/app/handler.ts';

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);
}
AppObjectRegistry.set('MESSAGE_SEPARATOR', Deno.args.at(-1));

async function handleRequest({ type, payload }: Messenger.JsonRpcRequest): Promise<void> {
// We're not handling notifications at the moment
Expand All @@ -108,22 +31,17 @@ async function handleRequest({ type, payload }: Messenger.JsonRpcRequest): Promi
AppObjectRegistry.set('logger', logger);

switch (true) {
case method.includes('app:construct'): {
const [appPackage] = params as [IParseAppPackageResult];
case method.startsWith('app:'): {
const result = await handleApp(method, params);

if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) {
return Messenger.sendInvalidParamsError(id);
if (result instanceof JsonRpcError) {
return Messenger.errorResponse({ id, error: result });
}

await handlInitializeApp(appPackage);

Messenger.successResponse({
id,
result: 'logs should go here as a response',
});
Messenger.successResponse({ id, result });
break;
}
case method.includes('slashcommand:'): {
case method.startsWith('slashcommand:'): {
const result = await slashcommandHandler(method, params);

if (result instanceof JsonRpcError) {
Expand Down
66 changes: 42 additions & 24 deletions src/server/runtime/AppsEngineDenoRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export function isValidOrigin(accessor: string): accessor is typeof ALLOWED_ACCE
return ALLOWED_ACCESSOR_METHODS.includes(accessor as any);
}

const MESSAGE_SEPARATOR = 'OkFQUF9TRVA6';

/**
* Resolves the absolute path of the Deno executable
* installed by deno-bin.
Expand Down Expand Up @@ -86,7 +88,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
const denoWrapperPath = getDenoWrapperPath();
const denoWrapperDir = path.dirname(path.join(denoWrapperPath, '..'));

this.deno = child_process.spawn(denoExePath, ['run', `--allow-read=${denoWrapperDir}/`, denoWrapperPath, '--subprocess', appPackage.info.id]);
this.deno = child_process.spawn(denoExePath, ['run', `--allow-read=${denoWrapperDir}/`, denoWrapperPath, '--subprocess', MESSAGE_SEPARATOR]);

this.setupListeners();
} catch {
Expand All @@ -99,6 +101,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
this.bridges = manager.getBridges();
}

// Debug purposes, could be deleted later
emit(eventName: string | symbol, ...args: any[]): boolean {
const hadListeners = super.emit(eventName, ...args);

Expand All @@ -120,7 +123,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
public async setupApp() {
await this.waitUntilReady();

this.sendRequest({ method: 'app:construct', params: [this.appPackage] });
await this.sendRequest({ method: 'app:construct', params: [this.appPackage] });
}

public async stopApp() {
Expand All @@ -132,7 +135,9 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
public async sendRequest(message: Pick<jsonrpc.RequestObject, 'method' | 'params'>): Promise<unknown> {
const id = String(Math.random()).substring(2);

this.deno.stdin.write(jsonrpc.request(id, message.method, message.params).serialize());
const request = jsonrpc.request(id, message.method, message.params);

this.deno.stdin.write(request.serialize());

return this.waitForResponse(id);
}
Expand All @@ -155,7 +160,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter {

private waitForResponse(id: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error('Request timed out')), this.options.timeout);
const timeoutId = setTimeout(() => reject(new Error(`Request "${id}" timed out`)), this.options.timeout);

this.once(`result:${id}`, (result: unknown, error: jsonrpc.IParsedObjectError['payload']['error']) => {
clearTimeout(timeoutId);
Expand All @@ -169,7 +174,7 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
});
}

private async onReady(): Promise<void> {
private onReady(): void {
this.state = 'ready';
}

Expand Down Expand Up @@ -339,35 +344,48 @@ export class DenoRuntimeSubprocessController extends EventEmitter {
logs = message.payload.error.data?.logs as ILoggerStorageEntry;
}

this.logStorage.storeEntries(logs);
await this.logStorage.storeEntries(logs);

this.emit(`result:${id}`, result, error);
}

private async parseOutput(chunk: Buffer): Promise<void> {
let message;
// Chunk can be multiple JSONRpc messages as the stdout read stream can buffer multiple messages
const messages = chunk.toString().split(MESSAGE_SEPARATOR);

try {
message = jsonrpc.parse(chunk.toString());
if (messages.length < 2) {
console.error('Invalid message format', messages);
return;
}

if (Array.isArray(message)) {
throw new Error('Invalid message format');
}
messages.forEach(async (m) => {
if (!m.length) return;

if (message.type === 'request' || message.type === 'notification') {
return this.handleIncomingMessage(message);
}
try {
const message = jsonrpc.parse(m);

if (message.type === 'success' || message.type === 'error') {
return this.handleResultMessage(message);
}
if (Array.isArray(message)) {
throw new Error('Invalid message format');
}

throw new Error();
} catch {
console.error('Invalid message format. What to do?', chunk.toString());
} finally {
console.log({ message });
}
if (message.type === 'request' || message.type === 'notification') {
return await this.handleIncomingMessage(message);
}

if (message.type === 'success' || message.type === 'error') {
return await this.handleResultMessage(message);
}

console.error('Unrecognized message type', message);
} catch (e) {
// SyntaxError is thrown when the message is not a valid JSON
if (e instanceof SyntaxError) {
return console.error('Failed to parse message', m);
}

console.error('Error executing handler', e);
}
});
}

private async parseError(chunk: Buffer): Promise<void> {
Expand Down
Loading