Skip to content
Draft
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
24 changes: 21 additions & 3 deletions .github/workflows/ci-test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ jobs:
run: echo "DEBUG_LOG_LEVEL=2" >> "$GITHUB_ENV"

- name: Start httpbin container and wait for it to be ready
if: inputs.type == 'api' || inputs.type == 'api-livechat'
if: startsWith(inputs.type, 'api')
run: |
docker compose -f docker-compose-ci.yml up -d httpbin

Expand All @@ -181,7 +181,7 @@ jobs:
# behavior on (rate-limiter bypass, short cache TTLs) while letting
# the deprecation logger log without throwing. Other suites use the
# docker-compose default of TEST_MODE='true'.
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
run: |
# when we are testing CE, we only need to start the rocketchat container
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait
Expand All @@ -192,7 +192,8 @@ jobs:
ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }}
TRANSPORTER: ${{ inputs.transporter }}
COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }}
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
APPS_ENGINE_RUNTIME_BACKEND: ${{ inputs.type == 'api-apps-node' && 'node' || '' }}
run: |
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait

Expand Down Expand Up @@ -229,6 +230,23 @@ jobs:
ls -la "$COVERAGE_DIR"
exit "${s:-0}"

# This step should be temporary, only here until we remove the deno-runtime
- name: E2E Test API (apps + node-runtime)
if: (inputs.type == 'api-apps-node' && inputs.release == 'ee')
working-directory: ./apps/meteor
env:
WEBHOOK_TEST_URL: 'http://httpbin'
IS_EE: 'true'
run: |
set -o xtrace

npm run testapi:apps || s=$?

docker compose -f ../../docker-compose-ci.yml stop

ls -la "$COVERAGE_DIR"
exit "${s:-0}"

- name: E2E Test API (Livechat)
if: inputs.type == 'api-livechat'
working-directory: ./apps/meteor
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,27 @@ jobs:
CR_PAT: ${{ secrets.CR_PAT }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

test-api-apps-node-ee:
name: 🔨 Test API Apps (node-runtime - EE)
needs: [checks, build-gh-docker-publish, release-versions]

uses: ./.github/workflows/ci-test-e2e.yml
with:
type: api-apps-node
release: ee
transporter: 'nats://nats:4222'
enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }}
mongodb-version: "['8.0']"
coverage: '8.0'
node-version: ${{ needs.release-versions.outputs.node-version }}
deno-version: ${{ needs.release-versions.outputs.deno-version }}
lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }}
gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }}
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

test-ui-ee:
name: 🔨 Test UI (EE)
needs: [checks, build-gh-docker-publish, release-versions]
Expand Down
15 changes: 15 additions & 0 deletions apps/meteor/.mocharc.api.apps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

/*
* Mocha configuration for REST API integration tests.
*/

module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: false,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
reporter: 'tests/end-to-end/reporter.ts',
spec: ['tests/end-to-end/apps/*'],
});
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"test:e2e:nyc": "nyc report --reporter=lcovonly",
"testapi": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.js",
"testapi:livechat": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.livechat.js",
"testapi:apps": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.apps.js",
"testunit": "yarn .testunit:definition && yarn .testunit:jest && yarn .testunit:server:cov",
"testunit-watch": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --watch --config ./.mocharc.js",
"typecheck": "meteor lint && cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --skipLibCheck",
Expand Down
1 change: 1 addition & 0 deletions docker-compose-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ services:
image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${DOCKER_TAG}${DOCKER_TAG_SUFFIX_ROCKETCHAT:-}
environment:
- 'TEST_MODE=${TEST_MODE:-true}'
- APPS_ENGINE_RUNTIME_BACKEND=${APPS_ENGINE_RUNTIME_BACKEND:-}
- DEBUG=${DEBUG:-}
- EXIT_UNHANDLEDPROMISEREJECTION=true
- MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0
Expand Down
207 changes: 207 additions & 0 deletions docs/proposals/shared-base-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Proposal: Shared Base Runtime for Apps (Deno + Node)

## Status

Draft

## Problem

The apps subprocess runtime exists today in two near-duplicate copies:

- `packages/apps/deno-runtime/` — the Deno implementation (files at root)
- `packages/apps/node-runtime/` — the Node implementation (files under `src/`)

A normalized diff of all 70 non-test source files (stripping import-path extensions,
`import type` vs `import`, and prettier/tabs-vs-spaces formatting) shows the two trees
are **~90% byte-identical logic**. The four largest apparent diffs (`BlockBuilder.ts`,
`lib/accessors/mod.ts`, `lib/ast/operations.ts`, `handlers/uikit/handler.ts`) each reduce
to **exactly 2 differing tokens** once sorted and whitespace-stripped — those tokens being
the relocated `require(...)` calls.

Maintaining two copies means every bug fix, accessor change, or handler addition must be
applied twice and kept in sync by hand. The goal is a single **base runtime** that owns all
shared logic, with each platform supplying a thin adapter for the genuinely
platform-specific surface.

## The Platform Boundary

Every genuine divergence between the two runtimes reduces to **one of 11 capabilities**.
The base runtime depends only on a `RuntimePlatform` interface; each runtime supplies a
concrete adapter.

```ts
interface RuntimePlatform {
// ── module resolution (the largest coupling: 19 files) ──
require(specifier: string): unknown; // apps-engine runtime classes + sandboxed app require
prepareEnvironment(): void; // deno: patch Socket.prototype._final; node: no-op

// ── transport ──
readStdin(): AsyncIterable<Uint8Array>; // deno: Deno.stdin.readable | node: process.stdin
writeStdout(bytes: Uint8Array): Promise<void>; // deno: writeAll(Deno.stdout) | node: process.stdout.write
writeStderr(bytes: Uint8Array): Promise<void>; // deno: writeAll(Deno.stderr) | node: process.stderr.write

// ── process lifecycle ──
pid: number; // Deno.pid | process.pid
argv: string[]; // Deno.args | process.argv
parseArgs(argv: string[]): ParsedArgs; // @std/cli | node:util
exit(code: number): never; // Deno.exit | process.exit
registerErrorHandlers(report): void; // addEventListener | process.on('uncaughtException')

// ── rpc response correlation ──
createResponseObserver(): ResponseObserver; // EventTarget(+ErrorEvent/CustomEvent) | EventEmitter

// ── file io ──
readFile(path: string): Promise<Uint8Array>; // Deno.open+toArrayBuffer | node:fs/promises readFile
}
```

Everything else — the handler layer, accessor layer, builders, extenders, modify/, AST,
room logic, codec, secureFields, logger — is logically identical and moves to the base
unchanged (modulo import-path/idiom normalization that converges automatically under one
toolchain).

## Decisions Taken

### `require` injection: init-time singleton (Option A)

The 19 `require`-coupled files are constructed deep in the tree (builders instantiated by
`ModifyCreator`, etc.), so threading `platform` through every constructor would be
invasive. Instead:

- The base exposes `setPlatform(platform)`, called **once** in `main()` before any handler
runs.
- `require`-coupled files read `platform.require` from a base-internal singleton.

This matches today's module-level `require` usage (Deno imports a shim; Node uses the global
CJS `require`) and keeps churn to the shared files near zero. The alternative — constructor
threading (pure DI) — was rejected because it touches every builder/extender/modify
signature for no functional gain.

### `construct.ts buildRequire`

The allow-lists (`ALLOWED_NATIVE_MODULES` / `ALLOWED_EXTERNAL_MODULES`) and the eval shell
are base. Only the specifier-prefix policy (`npm:` / `node:` handling, Buffer injection) and
`prepareEnvironment` differ → both fold into `platform`.

## Suggested Package Shape

```
packages/apps/
base-runtime/ # the ~58 single-source files + RuntimePlatform interface + setPlatform()
deno-runtime/ # adapter: require.ts, parseArgs, EventTarget observer, Deno io + bootstrap
node-runtime/ # adapter: loader-hook, parseArgs, EventEmitter observer, node io + bootstrap
```

## Disposition Legend

- **BASE** — moves to base unchanged (only import-path/idiom normalization).
- **BASE+require** — moves to base; the only coupling is `require()` → reads injected `platform.require`.
- **BASE+platform** — moves to base; needs a non-require capability injected.
- **SPLIT** — logic core moves to base; a thin slice stays in the adapter.
- **ADAPTER** — platform-specific implementation, one copy per runtime.

## Full Inventory

### `lib/` core

| File | Disposition | Injected capability / notes |
|---|---|---|
| `lib/codec.ts` | BASE+require | `require('.../App.js')` to load `App` class |
| `lib/secureFields.ts` | BASE | identical |
| `lib/sanitizeDeprecatedUsage.ts` | BASE | — |
| `lib/requestContext.ts` | BASE | — |
| `lib/room.ts` | BASE | only `??` / strictness idiom |
| `lib/roomFactory.ts` | BASE | — |
| `lib/wrapAppForRequest.ts` | BASE | — |
| `lib/logger.ts` | BASE | use Node's `implements ILogger` superset as canonical |
| `lib/messenger.ts` | SPLIT | `writeStdout` + `createResponseObserver` injected; Queue/encode logic is base |
| `lib/metricsCollector.ts` | BASE+platform | `writeStderr`, `pid` |
| `lib/parseArgs.ts` | ADAPTER | shape differs (`@std/cli` vs `node:util`); exposed via `platform.parseArgs` |
| `lib/require.ts` | ADAPTER (deno) | becomes the Deno `require` impl |
| `lib/loader-hook.ts` | ADAPTER (node) | Node `registerHooks` resolver |

### `lib/ast/`

| File | Disposition | Notes |
|---|---|---|
| `lib/ast/mod.ts` | BASE | drop `@deno-types` comments; use real acorn imports |
| `lib/ast/operations.ts` | BASE | type-assertion idiom only |
| `acorn.d.ts`, `acorn-walk.d.ts` | BASE | dedupe the two copies (deno root vs node `lib/ast/`) into one |

### `lib/accessors/`

| File | Disposition | Notes |
|---|---|---|
| `accessors/mod.ts` | BASE | 48-line "diff" was whitespace + `WithProxy` typing |
| `accessors/http.ts` | BASE | identical after norm |
| `accessors/formatResponseErrorHandler.ts` | BASE | byte-identical today |
| `accessors/notifier.ts` | BASE+require | — |
| `accessors/builders/BlockBuilder.ts` | BASE+require | loads BlockType/ElementType/TextObjectType |
| `accessors/builders/DiscussionBuilder.ts` | BASE+require | — |
| `accessors/builders/LivechatMessageBuilder.ts` | BASE+require | — |
| `accessors/builders/MessageBuilder.ts` | BASE+require | — |
| `accessors/builders/RoomBuilder.ts` | BASE+require | — |
| `accessors/builders/UserBuilder.ts` | BASE+require | — |
| `accessors/builders/VideoConferenceBuilder.ts` | BASE+require | — |
| `accessors/extenders/HttpExtender.ts` | BASE | byte-identical today |
| `accessors/extenders/MessageExtender.ts` | BASE+require | — |
| `accessors/extenders/RoomExtender.ts` | BASE+require | — |
| `accessors/extenders/VideoConferenceExtend.ts` | BASE+require | — |
| `accessors/modify/ModifyCreator.ts` | BASE+require | — |
| `accessors/modify/ModifyExtender.ts` | BASE+require | — |
| `accessors/modify/ModifyUpdater.ts` | BASE+require | — |

### `handlers/`

| File | Disposition | Notes |
|---|---|---|
| `handlers/api-handler.ts` | BASE | lint-directive idiom only |
| `handlers/outboundcomms-handler.ts` | BASE | — |
| `handlers/scheduler-handler.ts` | BASE | — |
| `handlers/slashcommand-handler.ts` | BASE | type-assertion idiom only |
| `handlers/videoconference-handler.ts` | BASE | — |
| `handlers/uikit/handler.ts` | BASE+require | — |
| `handlers/listener/handler.ts` | BASE+require | — |
| `handlers/lib/assertions.ts` | BASE | — |
| `handlers/app/handler.ts` | BASE | dispatch table |
| `handlers/app/handleInitialize.ts` | BASE | — |
| `handlers/app/handleGetStatus.ts` | BASE | — |
| `handlers/app/handleSetStatus.ts` | BASE+require | — |
| `handlers/app/handleOnEnable.ts` | BASE | — |
| `handlers/app/handleOnDisable.ts` | BASE | — |
| `handlers/app/handleOnInstall.ts` | BASE | — |
| `handlers/app/handleOnUninstall.ts` | BASE | — |
| `handlers/app/handleOnUpdate.ts` | BASE | — |
| `handlers/app/handleOnPreSettingUpdate.ts` | BASE | — |
| `handlers/app/handleOnSettingUpdated.ts` | BASE | — |
| `handlers/app/handleUploadEvents.ts` | BASE+platform | `readFile(path)` |
| `handlers/app/construct.ts` | SPLIT | `require`, `prepareEnvironment`, `buildRequire` specifier policy injected; sandbox-eval logic is base |

### Top-level / entry / config

| File | Disposition | Notes |
|---|---|---|
| `AppObjectRegistry.ts` | BASE | IIFE-paren idiom only |
| `error-handlers.ts` | SPLIT | notification-shape builder is base; `registerErrorHandlers` hook is adapter |
| `main.ts` | SPLIT | message loop is base; `readStdin` / `argv` / `exit` / observer-dispatch injected |
| `globals.d.ts` (node) | ADAPTER (node) | ambient types |
| `deno.jsonc`, `deno.runtime.jsonc`, `deno.lock`, `.gitignore` | ADAPTER (deno) | + import map for adapter wiring |
| `tsconfig.json` (node) | ADAPTER (node) | — |

## Tally

- **BASE (pure)**: 33 files
- **BASE+require**: 19 files
- **BASE+platform**: 2 files (`metricsCollector`, `handleUploadEvents`)
- **SPLIT**: 4 files (`main`, `messenger`, `construct`, `error-handlers`)
- **ADAPTER**: `require.ts` / `loader-hook.ts`, `parseArgs.ts`, `globals.d.ts`, config

→ **~58 of 62 logic files become single-source.** Only 4 files split, and the per-runtime
adapter is roughly ~250 lines total.

## Out of Scope (this proposal)

- Step-by-step migration ordering and the strategy for keeping both runtimes green during
the move (to be covered in a follow-up implementation plan).
- Test consolidation: both trees carry parallel `tests/` suites that would also collapse to
a single base suite running against each adapter.
2 changes: 1 addition & 1 deletion packages/apps/deno-runtime/acorn-walk.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type acorn from './acorn.d.ts';
import type acorn from './acorn.d';

export type FullWalkerCallback<TState> = (
node: acorn.AnyNode,
Expand Down
2 changes: 1 addition & 1 deletion packages/apps/deno-runtime/error-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Messenger from './lib/messenger.ts';
import * as Messenger from './lib/messenger';

export function unhandledRejectionListener(event: PromiseRejectionEvent) {
event.preventDefault();
Expand Down
8 changes: 4 additions & 4 deletions packages/apps/deno-runtime/handlers/api-handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint';
import { Defined, JsonRpcError } from 'jsonrpc-lite';

import { AppObjectRegistry } from '../AppObjectRegistry.ts';
import { AppAccessorsInstance } from '../lib/accessors/mod.ts';
import { RequestContext } from '../lib/requestContext.ts';
import { wrapComposedApp } from '../lib/wrapAppForRequest.ts';
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<JsonRpcError | Defined> {
const { method: call, params } = request;
Expand Down
10 changes: 5 additions & 5 deletions packages/apps/deno-runtime/handlers/app/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Socket } from 'node:net';

import type { IParseAppPackageResult } from '@rocket.chat/apps/dist/server/compiler/IParseAppPackageResult';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { require } from '../../lib/require.ts';
import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage.ts';
import { AppAccessorsInstance } from '../../lib/accessors/mod.ts';
import { RequestContext } from '../../lib/requestContext.ts';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { require } from '../../lib/require';
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'];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { AppObjectRegistry } from '../../AppObjectRegistry';

export default function handleGetStatus(): Promise<boolean> {
const app = AppObjectRegistry.get<App>('app');
Expand Down
8 changes: 4 additions & 4 deletions packages/apps/deno-runtime/handlers/app/handleInitialize.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { AppAccessorsInstance } from '../../lib/accessors/mod.ts';
import { RequestContext } from '../../lib/requestContext.ts';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts';
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<boolean> {
const app = AppObjectRegistry.get<App>('app');
Expand Down
8 changes: 4 additions & 4 deletions packages/apps/deno-runtime/handlers/app/handleOnDisable.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';

import { AppObjectRegistry } from '../../AppObjectRegistry.ts';
import { AppAccessorsInstance } from '../../lib/accessors/mod.ts';
import { RequestContext } from '../../lib/requestContext.ts';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts';
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<boolean> {
const app = AppObjectRegistry.get<App>('app');
Expand Down
Loading
Loading