首页
/ Rocket.Chat Apps Engine 默认运行时切换为 Node 子进程:APPS_ENGINE_RUNTIME_BACKEND 机制与双运行时实现解析

Rocket.Chat Apps Engine 默认运行时切换为 Node 子进程:APPS_ENGINE_RUNTIME_BACKEND 机制与双运行时实现解析

2026-09-05 15:26:40作者:段琳惟

本篇指南围绕 Rocket.Chat 仓库中一条 changeset 记录展开——Apps Engine 的默认应用运行时后端从 deno 切换为 node,并可通过环境变量 APPS_ENGINE_RUNTIME_BACKEND='deno' 回退到旧行为。读完本文,你将理解这个切换点在源码中的具体落位、Node 与 Deno 两个子进程运行时的完整启动链路、各自的沙箱安全模型与权限白名单,以及如何在 CI 与生产部署中正确选择、验证运行时后端。

Changeset 说明了什么

本次变更由 changeset 文件 记录,其内容非常克制,核心只有两点:

  • 默认后端变更:Apps Engine(@rocket.chat/apps)在启动 App 子进程时,默认运行时后端由 deno 改为 node
  • 回退方式:设置环境变量 APPS_ENGINE_RUNTIME_BACKEND='deno' 即可恢复之前的 Deno 后端行为。

Changeset 的 frontmatter 声明了受影响的两个包:@rocket.chat/apps(minor)与 @rocket.chat/meteor(minor)。这说明该变更属于兼容性良好的次要版本更新——回退手段保留了旧路径,未显式配置环境的部署将自动迁移到新默认值。

从仓库历史看,Node 运行时并非全新事物:packages/apps/CHANGELOG.md 中记录,Node 运行时最初是作为 Deno 运行时的替代方案引入的,通过 APPS_ENGINE_RUNTIME_BACKEND='node' 开启。本次 changeset 的本质是一次"默认值翻转":同一套环境变量机制,语义从"选择替代后端"变为"回退到原后端"。

切换点在源码中的位置

整个切换逻辑集中在 AppRuntimeManager 中,仅三行核心代码:

// packages/apps/src/server/managers/AppRuntimeManager.ts
const { APPS_ENGINE_RUNTIME_BACKEND = 'node' } = process.env;

export const nodeRuntimeFactory = (manager, appPackage, storageItem) =>
    new NodeRuntimeSubprocessController(manager, appPackage, storageItem);

export const denoRuntimeFactory = (manager, appPackage, storageItem) =>
    new DenoRuntimeSubprocessController(manager, appPackage, storageItem);

const defaultRuntimeFactory = APPS_ENGINE_RUNTIME_BACKEND === 'deno' ? denoRuntimeFactory : nodeRuntimeFactory;

AppRuntimeManager.ts#L22-L30。这里有几个值得注意的实现细节:

  1. 环境变量在模块加载时读取一次process.env 的解构发生在模块顶层,因此该值在整个 Meteor/Node 进程生命周期内固定——修改环境变量后必须重启服务进程才能生效,且对实例内所有 App 统一生效,不存在按 App 分别指定后端的机制。
  2. 判断是白名单式的:只有严格等于字符串 'deno' 时才使用 Deno 后端;未设置、空串或任何其它取值都会落入 Node 后端。这与 changeset 的表述("默认从 deno 变为 node")严格对应。
  3. 工厂可注入AppRuntimeManager 的构造函数接受 runtimeFactory 参数,默认使用 defaultRuntimeFactory,测试或特殊场景可以注入自定义工厂。

AppRuntimeManager 维护 subprocesses: Record<string, IRuntimeController>,以 appId 为键管理每个 App 的运行时实例:startRuntimeForApp() 负责创建并初始化子进程(setupApp() 失败时会调用 stopApp() 清理并抛出异常),runInSandbox() 通过 sendRequest() 向子进程转发执行请求,stopRuntime() 负责停机并从映射中移除。App 编译/加载流程中的入口调用位于 AppCompiler.ts#L24manager.getRuntime().startRuntimeForApp(packageResult, storage)

两个后端共享的基类:BaseRuntimeSubprocessController

无论选择 Node 还是 Deno 后端,子进程的控制逻辑都由抽象基类 BaseRuntimeSubprocessController 承担,源码注释明确说明其职责划分:

平台无关的逻辑——spawn、kill、restart、liveness、完整的 JSON-RPC 消息循环(bridge、result、error 处理)——放在基类;唯一的平台差异即"如何真正拉起子进程",委托给子类必须实现的 buildProcessConfiguration(),返回 { command, args, options }

基类中与运维相关的关键事实:

  • JSON-RPC over stdio:宿主与子进程之间使用 jsonrpc-lite 在标准输入/输出上传输请求,子进程侧由 stdout 传输层配合;
  • 存活探测:定义了 COMMAND_PONG = '_zPONG' 心跳协议(L25),配合 LivenessManager 检测子进程失活并触发重启(spawnId 递增用于区分历次拉起的进程);
  • 执行超时getRuntimeTimeout() 读取环境变量 APPS_ENGINE_RUNTIME_TIMEOUT默认 30000 毫秒,取值小于 0 或非有限数值时回退默认值(L29-L41)。该超时同样适用于两种后端;
  • 状态机:子进程状态在 uninitialized / ready / invalid / restarting / unknown / stopped 之间迁移;
  • 路径解析getAppsEngineDir() 通过 require.resolve('@rocket.chat/apps-engine/package.json') 动态解析 apps-engine 的绝对路径,保证在 monorepo 开发、Meteor bundle、独立 node_modules 等不同安装形态下都能定位(L48-L50)。

Node 后端实现

Node 后端的控制器为 NodeRuntimeSubprocessController,其 buildProcessConfiguration() 生成如下启动配置(L19-L42):

protected buildProcessConfiguration(): ProcessConfiguration {
    const allowedDirs = [
        this.tempFilePath,                                  // App 临时文件目录
        path.resolve(path.dirname(this.scriptRuntimePath), '..', '..'), // 运行时 dist 所在包根
        this.appsEnginePath,                                // @rocket.chat/apps-engine 目录
    ];

    const args = [
        '--permission',
        ...allowedDirs.map((dir) => `--allow-fs-read=${dir}`),
        this.scriptRuntimePath,   // require.resolve('../../../../node-runtime/dist/main.js')
        '--subprocess',
        this.appPackage.info.id,
        '--spawnId',
        String(this.spawnId++),
    ];

    return {
        command: this.nodeBin,        // 'node'
        args,
        options: { env: { PATH: process.env.PATH } },
    };
}

要点:

  • 入口脚本是编译产物 node-runtime/dist/main.js,通过 require.resolve 定位,与安装位置解耦;
  • 权限声明采用 --permission + 多个 --allow-fs-read=<dir> 参数,形式上与 Deno 的权限风格一致,允许读取的目录被限定为三个:App 临时目录、运行时包根目录、apps-engine 目录;
  • 环境变量最小化:子进程只继承 PATH,宿主环境中的其它变量对 App 不可见;源码注释强调"我们完全控制命令、参数和被执行脚本"(SECURITY 注释)。

子进程入口 node-runtime/src/main.ts 逻辑清晰:

import './lib/loader-hook';
// ...
if (!process.argv.includes('--subprocess')) {
    console.error('...It is not meant to be executed stand-alone; ...');
    process.exit(1);
}
setTransport(stdoutTransport);          // 通过 stdout 与宿主通信
setSandboxRequire(sandboxRequire);     // 受限的 require
setSandboxGlobals({});                 // Node 运行时不注入额外全局量
registerErrorListeners();
void startMainLoop();

值得注意的是它引用的是 @rocket.chat/apps/base-runtime/dist/... 编译产物(CommonJS),这与 Deno 侧形成对照(见下文)。

沙箱 require 白名单

Node 后端注入给 App 沙箱的 require 实现在 node-runtime/src/lib/require.ts 中,维护一份显式模块白名单:

const ALLOWED_MODULES = [
    'path', 'url', 'crypto', 'buffer', 'stream', 'net',
    'http', 'https', 'zlib', 'util', 'punycode', 'os',
    'querystring', 'fs',
    // External libraries
    'uuid', '@rocket.chat/apps-engine',
];

sandboxRequire() 先剥离 node: 前缀归一化 specifier,再做前缀匹配(因此 fsnode:fsnode:fs/promises@rocket.chat/apps-engine/** 均可解析),不在白名单内直接抛出 Module ${module} is not allowed。源码注释解释了白名单存在的原因:App 打包后调用 require 只可能出于三种目的——加载 native 模块、加载官方提供的外部 npm 包、加载 apps-engine 文件。

通信传输层 stdoutTransport 则是对 process.stdout.write 的 Promise 化封装:把消息(Uint8Array)写至标准输出,写错误时 reject。

Deno 后端实现(回退路径)

APPS_ENGINE_RUNTIME_BACKEND='deno' 时,实例化 DenoRuntimeSubprocessController。除生成命令外,它还有若干 Node 后端不需要的准备工作:

  1. 动态 import mapgenerateEphemeralDenoConfig() 读取静态 deno-runtime/deno.jsonc,向其中注入三个绝对路径映射——@rocket.chat/apps-engine/@rocket.chat/apps/base-runtime/@rocket.chat/apps/——再写出一份临时 deno.runtime.jsonc 到子进程临时目录(L28-L48)。这让 deno-runtime 的解析与安装位置无关;
  2. 符号链接规避:Deno 2.x 拒绝执行位于 node_modules 内的脚本,因此 ensureSymlink() 在临时目录建立 deno-runtime -> <deno-runtime 目录> 的目录级软链接(L50-L110);
  3. 缓存目录denoDirprocess.env.DENO_DIR,默认为包内 .deno-cache

其启动参数(L116-L151):

const args = [
    'run',
    '--cached-only',
    `--config=${this.denoEphemeralConfigPath}`,
    `--allow-read=${allowedDirs.join(',')}`,   // 临时目录、denoDir、包路径、apps-engine
    `--allow-env=${ALLOWED_ENVIRONMENT_VARIABLES.join(',')}`, // 仅 NODE_EXTRA_CA_CERTS
    this.denoRuntimePath,                     // <temp>/deno-runtime/main.ts
    '--subprocess', appId, '--spawnId', String(this.spawnId++),
];
// 若 App manifest 声明了 networking 权限,则插入 --allow-net

其中 ALLOWED_ENVIRONMENT_VARIABLES 只包含 NODE_EXTRA_CA_CERTS(被 https 模块访问),源码注释解释了原因:Deno 中访问未授权环境变量会直接抛错,而旧实现里等价操作仅返回 undefined,为避免兼容性问题必须显式声明。子进程环境变量同样只透传 PATH,外加 DENO_DIR

Deno 侧入口 deno-runtime/main.ts 与 Node 侧入口结构对称,但有两处刻意差异:

  • 导入 TypeScript 源码而非 dist:它从 @rocket.chat/apps/base-runtime/... 导入 base-runtime 的 TS 源文件。源码注释说明了原因——Deno 的 ESM import 遵守 import map,@rocket.chat/apps/ 映射到包根后,裸 specifier 都能解析到允许路径内;若改为导入编译后的 dist(CommonJS),其 require() 会绕过 import map 回落到 node_modules,落在子进程读权限白名单之外;
  • 平台行为补丁prepareEnvironment() 包裹 Socket.prototype._final,将回调延迟 1ms 执行,注释说明这是为弥补 Deno 与 Node 在 socket 管道数据可读时机上的差异;此外沙箱全局注入 { Buffer, Deno: undefined },显式把 Deno 遮蔽为 undefined,防止 App 代码探测到 Deno 平台对象。

两种后端的对照

维度 Node 后端(新默认) Deno 后端(回退)
启用条件 默认;APPS_ENGINE_RUNTIME_BACKEND 未设置或非 deno APPS_ENGINE_RUNTIME_BACKEND='deno'
可执行文件 node deno(宿主机必须已安装)
入口脚本 node-runtime/dist/main.js(编译产物) <temp>/deno-runtime/main.ts(经符号链接,TS 源码)
base-runtime 导入方式 dist(CommonJS) TS 源码(经 import map)
读取权限 3 个允许目录(临时目录、运行时包根、apps-engine) 4 个允许目录(临时目录、DENO_DIR、包路径、apps-engine)
网络权限 不通过 CLI 授予,由沙箱 require 白名单内的 http/https 决定 App manifest 声明 networking 权限时注入 --allow-net
环境变量 子进程仅继承 PATH 子进程仅继承 PATHDENO_DIR;沙箱仅可访问 NODE_EXTRA_CA_CERTS
额外全局注入 setSandboxGlobals({}) setSandboxGlobals({ Buffer, Deno: undefined })
沙箱 require 白名单 path/url/crypto/buffer/stream/net/http/https/zlib/util/punycode/os/querystring/fs/uuid/@rocket.chat/apps-engine deno-runtime/lib/require.tscreateRequire shim 提供

两者的共同约束:APPS_ENGINE_RUNTIME_TIMEOUT 超时(默认 30 秒)、JSON-RPC/stdio 通信、liveness PING/PONG 心跳、--subprocess 启动守卫(直接执行入口脚本会以退出码 1 提示"仅能作为 Apps-Engine 子进程运行")。

部署与验证建议

  • CI 中的用法:仓库的 docker-compose-ci.fips.yml 显式设置 APPS_ENGINE_RUNTIME_BACKEND=node(FIPS 合规环境的强制选择);而 docker-compose-ci.yml 采用透传写法 APPS_ENGINE_RUNTIME_BACKEND=${APPS_ENGINE_RUNTIME_BACKEND:-},即未定义时留空——在新默认值下等价于 Node 后端,测试矩阵因此默认覆盖 Node 运行时,需要时可在 CI 环境覆盖为 deno
  • 自托管部署:不设置任何变量即获得 Node 后端;要恢复 Deno 行为,在 Meteor 进程环境中设置 APPS_ENGINE_RUNTIME_BACKEND='deno' 并重启实例。由于取值发生在模块加载时,热改环境变量无效;
  • 前提差异:Deno 后端要求宿主环境存在 deno 可执行文件并依赖 --cached-only 离线缓存(DENO_DIR),而 Node 后端依赖运行环境自带的 node 与已编译的 node-runtime/dist 产物。从源码结构看,这也可能是本次翻转默认值的实际动因之一——Node 后端少了一层外部二进制与缓存预热的依赖,但仓库未在该 changeset 中说明动机,此处仅作推断;
  • 验证方式:确认当前生效后端无需额外接口——启动日志使用 appsEngine:runtime:<runtimeName> 的 debug 命名空间(runtimeNamenodedeno,见 BaseRuntimeSubprocessController.ts#L117),开启对应 debug 通道即可观察到实际拉起的运行时类型与每个 App 的 spawnId。

小结

这条简短的 changeset 背后是一次完整的架构默认值迁移:切换逻辑收敛在 AppRuntimeManager.ts 的工厂选择处,Node 与 Deno 两个后端共享 BaseRuntimeSubprocessController 的进程管理、JSON-RPC 与存活探测框架,各自在 buildProcessConfiguration() 中给出平台特定的命令与权限参数。对运维者而言,唯一的操作面就是 APPS_ENGINE_RUNTIME_BACKEND 一个环境变量:默认 Node、显式 deno 回退、重启生效、实例级统一。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384