Skip to content

Commit 784c287

Browse files
authored
feat(apps): introduce alternative node-runtime (#41019)
1 parent 31df377 commit 784c287

18 files changed

Lines changed: 255 additions & 84 deletions

File tree

.changeset/fruity-items-fix.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/apps': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
Adds an alternative runtime runner for apps. It can be enabled via environment variable `APPS_ENGINE_RUNTIME_BACKEND='node'`

.github/workflows/ci-test-e2e.yml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ jobs:
167167
run: echo "DEBUG_LOG_LEVEL=2" >> "$GITHUB_ENV"
168168

169169
- name: Start httpbin container and wait for it to be ready
170-
if: inputs.type == 'api' || inputs.type == 'api-livechat'
170+
if: startsWith(inputs.type, 'api')
171171
run: |
172172
docker compose -f docker-compose-ci.yml up -d httpbin
173173
@@ -186,7 +186,7 @@ jobs:
186186
# behavior on (rate-limiter bypass, short cache TTLs) while letting
187187
# the deprecation logger log without throwing. Other suites use the
188188
# docker-compose default of TEST_MODE='true'.
189-
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
189+
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
190190
run: |
191191
# when we are testing CE, we only need to start the rocketchat container
192192
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait
@@ -197,7 +197,8 @@ jobs:
197197
ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }}
198198
TRANSPORTER: ${{ inputs.transporter }}
199199
COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }}
200-
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
200+
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
201+
APPS_ENGINE_RUNTIME_BACKEND: ${{ inputs.type == 'api-apps-node' && 'node' || '' }}
201202
run: |
202203
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait
203204
@@ -234,6 +235,23 @@ jobs:
234235
ls -la "$COVERAGE_DIR"
235236
exit "${s:-0}"
236237
238+
# This step should be temporary, only here until we remove the deno-runtime
239+
- name: E2E Test API (apps + node-runtime)
240+
if: (inputs.type == 'api-apps-node' && inputs.release == 'ee')
241+
working-directory: ./apps/meteor
242+
env:
243+
WEBHOOK_TEST_URL: 'http://httpbin'
244+
IS_EE: 'true'
245+
run: |
246+
set -o xtrace
247+
248+
npm run testapi:apps || s=$?
249+
250+
docker compose -f ../../docker-compose-ci.yml stop
251+
252+
ls -la "$COVERAGE_DIR"
253+
exit "${s:-0}"
254+
237255
- name: E2E Test API (Livechat)
238256
if: inputs.type == 'api-livechat'
239257
working-directory: ./apps/meteor

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,27 @@ jobs:
657657
CR_PAT: ${{ secrets.CR_PAT }}
658658
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
659659

660+
test-api-apps-node-ee:
661+
name: 🔨 Test API Apps (node-runtime - EE)
662+
needs: [checks, build-gh-docker-publish, release-versions]
663+
664+
uses: ./.github/workflows/ci-test-e2e.yml
665+
with:
666+
type: api-apps-node
667+
release: ee
668+
transporter: 'nats://nats:4222'
669+
enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }}
670+
mongodb-version: "['8.0']"
671+
coverage: '8.0'
672+
node-version: ${{ needs.release-versions.outputs.node-version }}
673+
deno-version: ${{ needs.release-versions.outputs.deno-version }}
674+
lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }}
675+
gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }}
676+
secrets:
677+
CR_USER: ${{ secrets.CR_USER }}
678+
CR_PAT: ${{ secrets.CR_PAT }}
679+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
680+
660681
test-ui-ee:
661682
name: 🔨 Test UI (EE)
662683
needs: [checks, build-gh-docker-publish, release-versions]

apps/meteor/.mocharc.api.apps.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
/*
4+
* Mocha configuration for REST API integration tests.
5+
*/
6+
7+
module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
8+
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
9+
timeout: 10000,
10+
bail: false,
11+
retries: 0,
12+
file: 'tests/end-to-end/teardown.ts',
13+
reporter: 'tests/end-to-end/reporter.ts',
14+
spec: ['tests/end-to-end/apps/*'],
15+
});

apps/meteor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"test:e2e:nyc": "nyc report --reporter=lcovonly",
5757
"testapi": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.js",
5858
"testapi:livechat": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.livechat.js",
59+
"testapi:apps": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.apps.js",
5960
"testunit": "yarn .testunit:definition && yarn .testunit:jest && yarn .testunit:server:cov",
6061
"testunit-watch": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --watch --config ./.mocharc.js",
6162
"typecheck": "meteor lint && cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --skipLibCheck",

docker-compose-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ services:
1414
image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${DOCKER_TAG}${DOCKER_TAG_SUFFIX_ROCKETCHAT:-}
1515
environment:
1616
- 'TEST_MODE=${TEST_MODE:-true}'
17+
- APPS_ENGINE_RUNTIME_BACKEND=${APPS_ENGINE_RUNTIME_BACKEND:-}
1718
- DEBUG=${DEBUG:-}
1819
- EXIT_UNHANDLEDPROMISEREJECTION=true
1920
- MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0

packages/apps/deno-runtime/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { stdoutTransport } from './lib/transports/stdoutTransport';
1717

1818
if (!process.argv.includes('--subprocess')) {
1919
console.error(`
20-
This is a Deno wrapper for Rocket.Chat Apps. It is not meant to be executed stand-alone;
20+
This is the Deno wrapper for the Rocket.Chat Apps runtime. It is not meant to be executed stand-alone;
2121
It is instead meant to be executed as a subprocess by the Apps-Engine framework.
2222
`);
2323

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Messenger from '@rocket.chat/apps/base-runtime/dist/lib/messenger';
2+
3+
export default function registerErrorListeners() {
4+
process.on('uncaughtException', (error: Error, origin: 'uncaughtException' | 'unhandledRejection') => {
5+
Messenger.sendNotification({
6+
method: origin,
7+
params: [error.stack || error],
8+
});
9+
});
10+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { registerHooks } from 'node:module';
2+
import path from 'node:path';
3+
4+
// This file compiles to dist/lib/loader-hook.js.
5+
// Three levels up from dist/lib/ lands on packages/apps/ — the @rocket.chat/apps package root.
6+
const appsPackageDir = path.resolve(__dirname, '../../..');
7+
8+
const PACKAGE_PREFIX = '@rocket.chat/apps';
9+
10+
registerHooks({
11+
resolve(specifier, context, nextResolve) {
12+
if (specifier === PACKAGE_PREFIX || specifier.startsWith(`${PACKAGE_PREFIX}/`)) {
13+
const subpath = specifier.slice(PACKAGE_PREFIX.length).replace(/^\//, '');
14+
const localPath = subpath ? path.join(appsPackageDir, subpath) : appsPackageDir;
15+
return nextResolve(localPath, context);
16+
}
17+
return nextResolve(specifier, context);
18+
},
19+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const ALLOWED_MODULES = [
2+
'path',
3+
'url',
4+
'crypto',
5+
'buffer',
6+
'stream',
7+
'net',
8+
'http',
9+
'https',
10+
'zlib',
11+
'util',
12+
'punycode',
13+
'os',
14+
'querystring',
15+
'fs',
16+
// External libraries
17+
'uuid',
18+
'@rocket.chat/apps-engine',
19+
];
20+
21+
// As the apps are bundled, the only times they will call require are
22+
// 1. To require native modules
23+
// 2. To require external npm packages we may provide
24+
// 3. To require apps-engine files
25+
export const sandboxRequire = (module: string) => {
26+
// Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto'
27+
const normalized = module.replace('node:', '');
28+
29+
// We allow variants like 'fs', 'node:fs' or 'node:fs/promises', or even '@rocket.chat/apps-engine/**'
30+
if (!ALLOWED_MODULES.some((mod) => normalized.startsWith(mod))) {
31+
throw new Error(`Module ${module} is not allowed`);
32+
}
33+
34+
// This is THE purpose of this function, we can't escape a dinamyc require call
35+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, import/no-dynamic-require, @typescript-eslint/no-require-imports
36+
return require(normalized);
37+
};

0 commit comments

Comments
 (0)