Skip to content

Commit 987cbcd

Browse files
committed
refactor(base-runtime): extract shared base-runtime from node/deno trees
Move the 52 shared runtime files (handlers, accessors, lib, AST, AppObjectRegistry, and their tests) into a single base-runtime/ tree, deleting the duplicated copies that lived in both node-runtime/src and deno-runtime. The base is now the single source for everything that is not a platform seam. Three base-only changes make the tree self-contained: - construct.ts becomes single-source: sandbox require and the eval-shell globals are injected via setSandboxRequire/setSandboxGlobals (defaults no-op), the App import is type-only, and the platform Socket patch is removed (hoisted to the Deno adapter bootstrap). - the message loop is extracted to messageLoop.ts (startMessageLoop), leaving transport/error-listener wiring to each adapter. - the anonymous Queue class in messenger.ts is named so the base can emit declarations; the base messenger test no longer references a platform transport. base-runtime/tsconfig.json compiles the tree with the runtime-flavored settings (nodenext, es2023, types: node) to base-runtime/dist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(base-runtime): messageLoop to mainLoop refactor(base-runtime): better directory structure install base-runtime dependencies in the apps package fix(base-runtime): import fixes refactor(base-runtime): move the full require logic out of construct.ts Module resolution is the responsibility of the platform
1 parent 9a80dd7 commit 987cbcd

76 files changed

Lines changed: 8036 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export type Maybe<T> = T | null | undefined;
2+
3+
export const AppObjectRegistry = new (class {
4+
registry: Record<string, unknown> = {};
5+
6+
public get<T>(key: string): Maybe<T> {
7+
return this.registry[key] as Maybe<T>;
8+
}
9+
10+
public set(key: string, value: unknown): void {
11+
this.registry[key] = value;
12+
}
13+
14+
public has(key: string): boolean {
15+
return key in this.registry;
16+
}
17+
18+
public delete(key: string): void {
19+
delete this.registry[key];
20+
}
21+
22+
public clear(): void {
23+
this.registry = {};
24+
}
25+
})();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint';
2+
import type { Defined } from 'jsonrpc-lite';
3+
import { JsonRpcError } from 'jsonrpc-lite';
4+
5+
import { AppObjectRegistry } from '../AppObjectRegistry';
6+
import { AppAccessorsInstance } from '../lib/accessors/mod';
7+
import type { RequestContext } from '../lib/requestContext';
8+
import { wrapComposedApp } from '../lib/wrapAppForRequest';
9+
10+
export default async function apiHandler(request: RequestContext): Promise<JsonRpcError | Defined> {
11+
const { method: call, params } = request;
12+
const [, /* always "api" */ ...parts] = call.split(':');
13+
const httpMethod = parts.pop();
14+
const path = parts.join(':');
15+
16+
const endpoint = AppObjectRegistry.get<IApiEndpoint>(`api:${path}`);
17+
const { logger } = request.context;
18+
19+
if (!endpoint) {
20+
return new JsonRpcError(`Endpoint ${path} not found`, -32000);
21+
}
22+
23+
const method = endpoint[httpMethod as keyof IApiEndpoint];
24+
25+
if (typeof method !== 'function') {
26+
return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000);
27+
}
28+
29+
const [requestData, endpointInfo] = params as Array<unknown>;
30+
31+
logger.debug(`${path}'s ${call} is being executed...`, requestData);
32+
33+
try {
34+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
35+
const result = await (method as Function).apply(wrapComposedApp(endpoint, request), [
36+
requestData,
37+
endpointInfo,
38+
AppAccessorsInstance.getReader(),
39+
AppAccessorsInstance.getModifier(),
40+
AppAccessorsInstance.getHttp(),
41+
AppAccessorsInstance.getPersistence(),
42+
]);
43+
44+
logger.debug(`${path}'s ${call} was successfully executed.`);
45+
46+
return result;
47+
} catch (e) {
48+
logger.debug(`${path}'s ${call} was unsuccessful.`);
49+
return new JsonRpcError(e.message || 'Internal server error', -32000);
50+
}
51+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { IParseAppPackageResult } from '@rocket.chat/apps/dist/server/compiler/IParseAppPackageResult';
2+
import type { App } from '@rocket.chat/apps-engine/definition/App';
3+
4+
import { AppObjectRegistry } from '../../AppObjectRegistry';
5+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
6+
import type { RequestContext } from '../../lib/requestContext';
7+
import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage';
8+
9+
/**
10+
* A platform-dependent `require` used to resolve the modules an app is allowed
11+
* to load (native `node:` modules, a small allow-list of npm packages, and
12+
* apps-engine files). Each runtime injects its own via {@link setSandboxRequire}
13+
* — Node hands over its global `require`, Deno hands over its `createRequire`
14+
* shim that knows how to resolve compiled apps-engine paths.
15+
*/
16+
type SandboxRequire = (module: string) => unknown;
17+
18+
function defaultSandboxRequire(): never {
19+
throw new Error('No sandbox require has been injected; the runtime adapter must call setSandboxRequire() during bootstrap');
20+
}
21+
22+
let sandboxRequire: SandboxRequire = defaultSandboxRequire;
23+
24+
export function setSandboxRequire(newRequire: SandboxRequire): void {
25+
sandboxRequire = newRequire;
26+
}
27+
28+
/**
29+
* Extra globals bound into the app's eval shell on top of the common ones
30+
* (`exports`, `module`, `require`, `console`, `globalThis`). Node needs none;
31+
* Deno injects a `Buffer` and shadows `Deno` with `undefined`. Injecting them
32+
* as data keeps the eval-shell skeleton single-source.
33+
*/
34+
type SandboxGlobals = Record<string, unknown>;
35+
36+
let sandboxGlobals: SandboxGlobals = {};
37+
38+
export function setSandboxGlobals(globals: SandboxGlobals): void {
39+
sandboxGlobals = globals;
40+
}
41+
42+
function wrapAppCode(code: string): (require: SandboxRequire) => Promise<Record<string, unknown>> {
43+
const globals = sandboxGlobals;
44+
// The common globals are bound by name; any platform-specific extras are
45+
// spread in by name from the injected `sandboxGlobals`, so the shell
46+
// skeleton stays identical across runtimes.
47+
const extraNames = Object.keys(globals);
48+
const extraParams = extraNames.length ? `,${extraNames.join(',')}` : '';
49+
const extraArgs = extraNames.map((name) => `,__globals[${JSON.stringify(name)}]`).join('');
50+
51+
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- This is the reason we run in a separate process
52+
const fn = new Function(
53+
'require',
54+
'__globals',
55+
`
56+
const exports = {};
57+
const module = { exports };
58+
const _error = console.error.bind(console);
59+
const _console = {
60+
log: _error,
61+
error: _error,
62+
debug: _error,
63+
info: _error,
64+
warn: _error,
65+
};
66+
67+
const result = (async (exports,module,require,console,globalThis${extraParams}) => {
68+
${code};
69+
})(exports,module,require,_console,undefined${extraArgs});
70+
71+
return result.then(() => module.exports);`,
72+
) as (require: SandboxRequire, globals: SandboxGlobals) => Promise<Record<string, unknown>>;
73+
74+
return (require: SandboxRequire) => fn(require, globals);
75+
}
76+
77+
type AppConstructor = new (...args: unknown[]) => App;
78+
79+
export default async function handleConstructApp(request: RequestContext): Promise<boolean> {
80+
const { params } = request;
81+
82+
if (!Array.isArray(params)) {
83+
throw new Error('Invalid params', { cause: 'invalid_param_type' });
84+
}
85+
86+
const [appPackage] = params as [IParseAppPackageResult];
87+
88+
if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) {
89+
throw new Error('Invalid params', { cause: 'invalid_param_type' });
90+
}
91+
92+
AppObjectRegistry.set('id', appPackage.info.id);
93+
const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]);
94+
95+
const exports = await wrapAppCode(source)(sandboxRequire);
96+
97+
// This is the same naive logic we've been using in the App Compiler
98+
// Applying the correct type here is quite difficult because of the dynamic nature of the code
99+
const appClass = Object.values(exports)[0] as AppConstructor;
100+
101+
const app = new appClass(appPackage.info, request.context.logger, AppAccessorsInstance.getDefaultAppAccessors());
102+
103+
if (typeof app.getName !== 'function') {
104+
throw new Error('App must contain a getName function');
105+
}
106+
107+
if (typeof app.getNameSlug !== 'function') {
108+
throw new Error('App must contain a getNameSlug function');
109+
}
110+
111+
if (typeof app.getVersion !== 'function') {
112+
throw new Error('App must contain a getVersion function');
113+
}
114+
115+
if (typeof app.getID !== 'function') {
116+
throw new Error('App must contain a getID function');
117+
}
118+
119+
if (typeof app.getDescription !== 'function') {
120+
throw new Error('App must contain a getDescription function');
121+
}
122+
123+
if (typeof app.getRequiredApiVersion !== 'function') {
124+
throw new Error('App must contain a getRequiredApiVersion function');
125+
}
126+
127+
AppObjectRegistry.set('app', app);
128+
129+
return true;
130+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
3+
4+
import { AppObjectRegistry } from '../../AppObjectRegistry';
5+
6+
export default function handleGetStatus(): Promise<AppStatus> {
7+
const app = AppObjectRegistry.get<App>('app');
8+
9+
if (typeof app?.getStatus !== 'function') {
10+
throw new Error('App must contain a getStatus function', {
11+
cause: 'invalid_app',
12+
});
13+
}
14+
15+
return app.getStatus();
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default async function handleInitialize(request: RequestContext): Promise<boolean> {
9+
const app = AppObjectRegistry.get<App>('app');
10+
11+
if (typeof app?.initialize !== 'function') {
12+
throw new Error('App must contain an initialize function', {
13+
cause: 'invalid_app',
14+
});
15+
}
16+
17+
await app.initialize.call(
18+
wrapAppForRequest(app, request),
19+
AppAccessorsInstance.getConfigurationExtend(),
20+
AppAccessorsInstance.getEnvironmentRead(),
21+
);
22+
23+
return true;
24+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default async function handleOnDisable(request: RequestContext): Promise<boolean> {
9+
const app = AppObjectRegistry.get<App>('app');
10+
11+
if (typeof app?.onDisable !== 'function') {
12+
throw new Error('App must contain an onDisable function', {
13+
cause: 'invalid_app',
14+
});
15+
}
16+
17+
await app.onDisable.call(wrapAppForRequest(app, request), AppAccessorsInstance.getConfigurationModify());
18+
19+
return true;
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default function handleOnEnable(request: RequestContext): Promise<boolean> {
9+
const app = AppObjectRegistry.get<App>('app');
10+
11+
if (typeof app?.onEnable !== 'function') {
12+
throw new Error('App must contain an onEnable function', {
13+
cause: 'invalid_app',
14+
});
15+
}
16+
17+
return app.onEnable.call(
18+
wrapAppForRequest(app, request),
19+
AppAccessorsInstance.getEnvironmentRead(),
20+
AppAccessorsInstance.getConfigurationModify(),
21+
);
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default async function handleOnInstall(request: RequestContext): Promise<boolean> {
9+
const { params } = request;
10+
const app = AppObjectRegistry.get<App>('app');
11+
12+
if (typeof app?.onInstall !== 'function') {
13+
throw new Error('App must contain an onInstall function', {
14+
cause: 'invalid_app',
15+
});
16+
}
17+
18+
if (!Array.isArray(params)) {
19+
throw new Error('Invalid params', { cause: 'invalid_param_type' });
20+
}
21+
22+
const [context] = params as [Record<string, unknown>];
23+
24+
await app.onInstall.call(
25+
wrapAppForRequest(app, request),
26+
context,
27+
AppAccessorsInstance.getReader(),
28+
AppAccessorsInstance.getHttp(),
29+
AppAccessorsInstance.getPersistence(),
30+
AppAccessorsInstance.getModifier(),
31+
);
32+
33+
return true;
34+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default function handleOnPreSettingUpdate(request: RequestContext): Promise<object> {
9+
const { params } = request;
10+
const app = AppObjectRegistry.get<App>('app');
11+
12+
if (typeof app?.onPreSettingUpdate !== 'function') {
13+
throw new Error('App must contain an onPreSettingUpdate function', {
14+
cause: 'invalid_app',
15+
});
16+
}
17+
18+
if (!Array.isArray(params)) {
19+
throw new Error('Invalid params', { cause: 'invalid_param_type' });
20+
}
21+
22+
const [setting] = params as [Record<string, unknown>];
23+
24+
return app.onPreSettingUpdate.call(
25+
wrapAppForRequest(app, request),
26+
setting,
27+
AppAccessorsInstance.getConfigurationModify(),
28+
AppAccessorsInstance.getReader(),
29+
AppAccessorsInstance.getHttp(),
30+
);
31+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { App } from '@rocket.chat/apps-engine/definition/App';
2+
3+
import { AppObjectRegistry } from '../../AppObjectRegistry';
4+
import { AppAccessorsInstance } from '../../lib/accessors/mod';
5+
import type { RequestContext } from '../../lib/requestContext';
6+
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
7+
8+
export default async function handleOnSettingUpdated(request: RequestContext): Promise<boolean> {
9+
const { params } = request;
10+
const app = AppObjectRegistry.get<App>('app');
11+
12+
if (typeof app?.onSettingUpdated !== 'function') {
13+
throw new Error('App must contain an onSettingUpdated function', {
14+
cause: 'invalid_app',
15+
});
16+
}
17+
18+
if (!Array.isArray(params)) {
19+
throw new Error('Invalid params', { cause: 'invalid_param_type' });
20+
}
21+
22+
const [setting] = params as [Record<string, unknown>];
23+
24+
await app.onSettingUpdated.call(
25+
wrapAppForRequest(app, request),
26+
setting,
27+
AppAccessorsInstance.getConfigurationModify(),
28+
AppAccessorsInstance.getReader(),
29+
AppAccessorsInstance.getHttp(),
30+
);
31+
32+
return true;
33+
}

0 commit comments

Comments
 (0)