Skip to content

Commit 6f43227

Browse files
committed
feat(apps): introduce node-runtime
refactor(apps): reduce node-runtime to a thin base adapter node-runtime now keeps only its platform seam: main.ts is a bootstrap that wires the stdout transport, the sandbox require (Node's global require) and empty sandbox globals, registers error listeners, then invokes the base message loop. error-handlers and stdoutTransport import the base messenger via the compiled specifier @rocket.chat/apps/base-runtime/dist/..., resolved by the existing loader-hook. Drop the stray debug console.error from loader-hook now that real runtime imports flow through it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> node-runtime main.ts comment
1 parent d18dac5 commit 6f43227

9 files changed

Lines changed: 136 additions & 3 deletions

File tree

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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
if (!ALLOWED_MODULES.includes(normalized)) {
30+
throw new Error(`Module ${module} is not allowed`);
31+
}
32+
33+
// This is THE purpose of this function, we can't escape a dinamyc require call
34+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, import/no-dynamic-require, @typescript-eslint/no-require-imports
35+
return require(normalized);
36+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Transport } from '@rocket.chat/apps/base-runtime/dist/lib/messenger';
2+
3+
/**
4+
* Transport that writes messages to the process' standard output.
5+
*
6+
* This is the transport used when the runtime is executed as a subprocess by
7+
* the Apps-Engine framework, and it is specific to platforms that expose a
8+
* Node-compatible `process.stdout`.
9+
*/
10+
export const stdoutTransport: Transport = {
11+
async send(message: Uint8Array): Promise<void> {
12+
await new Promise<void>((resolve, reject) => {
13+
process.stdout.write(message, (err) => (err ? reject(err) : resolve()));
14+
});
15+
},
16+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import './lib/loader-hook';
2+
3+
import { setSandboxGlobals, setSandboxRequire } from '@rocket.chat/apps/base-runtime/dist/handlers/app/construct';
4+
import { setTransport } from '@rocket.chat/apps/base-runtime/dist/lib/messenger';
5+
import { startMainLoop } from '@rocket.chat/apps/base-runtime/dist/mainLoop';
6+
7+
import registerErrorListeners from './error-handlers';
8+
import { sandboxRequire } from './lib/require';
9+
import { stdoutTransport } from './lib/transports/stdoutTransport';
10+
11+
if (!process.argv.includes('--subprocess')) {
12+
console.error(
13+
new TextEncoder().encode(`
14+
This is the Node wrapper for the Rocket.Chat Apps runtime. It is not meant to be executed stand-alone;
15+
It is instead meant to be executed as a subprocess by the Apps-Engine framework.
16+
`),
17+
);
18+
19+
process.exit(1);
20+
}
21+
22+
// This runtime communicates with the Apps-Engine host through stdout
23+
setTransport(stdoutTransport);
24+
25+
// The sandbox `require` handed to the app is Node's own global `require`; it
26+
// needs no extra globals beyond the common ones the base eval shell binds.
27+
setSandboxRequire(sandboxRequire);
28+
setSandboxGlobals({});
29+
30+
registerErrorListeners();
31+
32+
void startMainLoop();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "./src",
5+
"outDir": "./dist",
6+
"baseUrl": ".",
7+
"types": ["node"],
8+
"lib": ["es2023"],
9+
"module": "nodenext",
10+
"moduleResolution": "nodenext",
11+
"target": "es2023",
12+
"declaration": true,
13+
"paths": {
14+
"@rocket.chat/apps/*": ["../*"]
15+
}
16+
},
17+
"include": ["./src/**/*"]
18+
}

packages/apps/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
"files": [
88
"dist/",
99
"base-runtime/",
10+
"node-runtime/",
1011
"deno-runtime/",
1112
".deno-cache/"
1213
],
1314
"scripts": {
14-
"build": "run-s build:clean build:default build:base-runtime build:deno-cache",
15+
"build": "run-s build:clean build:default build:base-runtime build:node-runtime build:deno-cache",
1516
"build:clean": "rimraf dist base-runtime/dist",
1617
"build:default": "tsc -p tsconfig.json",
1718
"build:base-runtime": "tsc -p base-runtime/tsconfig.json",
19+
"build:node-runtime": "tsc -p node-runtime/tsconfig.json",
1820
"build:deno-cache": "node scripts/deno-cache.js",
1921
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
2022
"lint": "eslint .",

packages/apps/turbo.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"build": {
55
"dependsOn": ["^build"],
66
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.tool-versions"],
7-
"outputs": ["base-runtime/**", "deno-runtime/**", "scripts/**", ".deno-cache/**", "dist/**"]
7+
"outputs": ["base-runtime/**", "node-runtime/**", "deno-runtime/**", "scripts/**", ".deno-cache/**", "dist/**"]
88
}
99
}
1010
}

0 commit comments

Comments
 (0)