Skip to content

Commit 24b3671

Browse files
authored
feat: add per-integration scriptTranspile flag to opt-out of Babel (RocketChat#40160)
1 parent 2b4bd13 commit 24b3671

10 files changed

Lines changed: 226 additions & 104 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rocket.chat/meteor': minor
3+
'@rocket.chat/core-typings': minor
4+
'@rocket.chat/rest-typings': minor
5+
---
6+
7+
Adds a `skipTranspile` flag (default `false`) to webhook integrations. When set to `true`, the integration script is stored as-is without Babel transpilation — matching the 9.0.0 default where Babel is removed entirely. Admins can flip the flag per-integration to validate strict-mode compatibility before upgrading. The field is deprecated and will be removed in 9.0.0.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import vm from 'node:vm';
2+
3+
import { transformSync } from '@babel/core';
4+
import presetEnv from '@babel/preset-env';
5+
6+
/**
7+
* Compile or validate a user-supplied integration script for storage in
8+
* `scriptCompiled`.
9+
*
10+
* When `transpile` is `true` (the default, controlled by each integration's
11+
* `skipTranspile` flag), the script is transpiled with `@babel/core +
12+
* @babel/preset-env` — the historical behavior. When `false`, the script is
13+
* validated with Node's built-in `vm.Script` and stored as-is, matching the
14+
* 9.0.0 default where Babel transpilation is removed entirely.
15+
*
16+
* Integration scripts run inside `isolated-vm`, which embeds modern V8 and
17+
* handles ES2023+ natively. The transpilation only exists to preserve the
18+
* sloppy-mode semantics (implicit globals in class methods, `this` in nested
19+
* functions, etc.) that early scripts relied on. Admins can flip
20+
* `skipTranspile: true` per integration to test strict-mode compatibility
21+
* before the 9.0.0 upgrade.
22+
*
23+
* Returns `{ script }` on success or `{ error }` with the same
24+
* `{ name, message, stack }` shape persisted in `scriptError`.
25+
*/
26+
export function compileIntegrationScript(
27+
script: string,
28+
{ transpile }: { transpile: boolean },
29+
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
30+
if (!transpile) {
31+
return validateOnly(script);
32+
}
33+
34+
return transpileWithBabel(script);
35+
}
36+
37+
function validateOnly(
38+
script: string,
39+
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
40+
try {
41+
new vm.Script(`(function(){${script}})`);
42+
return { script };
43+
} catch (e) {
44+
if (e instanceof SyntaxError) {
45+
const { name, message, stack } = e;
46+
return { error: { name, message, stack } };
47+
}
48+
throw e;
49+
}
50+
}
51+
52+
function transpileWithBabel(
53+
script: string,
54+
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
55+
try {
56+
const result = transformSync(script, {
57+
presets: [presetEnv],
58+
compact: true,
59+
minified: true,
60+
comments: false,
61+
});
62+
63+
return { script: result?.code ?? script };
64+
} catch (e) {
65+
if (e instanceof Error) {
66+
const { name, message, stack } = e;
67+
return { error: { name, message, stack } };
68+
}
69+
throw e;
70+
}
71+
}

apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { transformSync } from '@babel/core';
2-
import presetEnv from '@babel/preset-env';
31
import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
42
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
5-
import { pick } from '@rocket.chat/tools';
63
import { Match } from 'meteor/check';
74
import { Meteor } from 'meteor/meteor';
85

6+
import { compileIntegrationScript } from './compileIntegrationScript';
97
import { isScriptEngineFrozen } from './validateScriptEngine';
108
import { parseCSV } from '../../../../lib/utils/parseCSV';
119
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission';
@@ -172,28 +170,20 @@ export const validateOutgoingIntegration = async function (
172170
delete integrationData.triggerWords;
173171
}
174172

175-
// Only compile the script if it is enabled and using a sandbox that is not frozen
173+
// Default to transpiling with Babel for backwards compatibility; integrations
174+
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
175+
const skipTranspile = integration.skipTranspile === true;
176+
integrationData.skipTranspile = skipTranspile;
177+
176178
if (
177179
!isScriptEngineFrozen(integrationData.scriptEngine) &&
178180
integration.scriptEnabled === true &&
179181
integration.script &&
180182
integration.script.trim() !== ''
181183
) {
182-
try {
183-
const result = transformSync(integration.script, {
184-
presets: [presetEnv],
185-
compact: true,
186-
minified: true,
187-
comments: false,
188-
});
189-
190-
// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
191-
integrationData.scriptCompiled = result?.code ?? undefined;
192-
integrationData.scriptError = undefined;
193-
} catch (e) {
194-
integrationData.scriptCompiled = undefined;
195-
integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined;
196-
}
184+
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
185+
integrationData.scriptCompiled = script;
186+
integrationData.scriptError = error;
197187
}
198188

199189
if (typeof integration.runOnEdits !== 'undefined') {

apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import { transformSync } from '@babel/core';
2-
import presetEnv from '@babel/preset-env';
31
import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat/core-typings';
42
import type { ServerMethods } from '@rocket.chat/ddp-client';
53
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
64
import { Random } from '@rocket.chat/random';
75
import { removeEmpty } from '@rocket.chat/tools';
86
import { Match, check } from 'meteor/check';
97
import { Meteor } from 'meteor/meteor';
10-
import _ from 'underscore';
118

129
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
1310
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
1411
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
12+
import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
1513
import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine';
1614

1715
const validChannelChars = ['@', '#'];
@@ -92,9 +90,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
9290
});
9391
}
9492

93+
// Default to transpiling with Babel for backwards compatibility; integrations
94+
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
95+
const skipTranspile = integration.skipTranspile === true;
96+
9597
const integrationData: IIncomingIntegration = {
9698
...integration,
9799
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
100+
skipTranspile,
98101
type: 'webhook-incoming',
99102
channel: channels,
100103
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false,
@@ -104,27 +107,19 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
104107
_createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
105108
};
106109

107-
// Only compile the script if it is enabled and using a sandbox that is not frozen
108110
if (
109111
!isScriptEngineFrozen(integrationData.scriptEngine) &&
110112
integration.scriptEnabled === true &&
111113
integration.script &&
112114
integration.script.trim() !== ''
113115
) {
114-
try {
115-
const result = transformSync(integration.script, {
116-
presets: [presetEnv],
117-
compact: true,
118-
minified: true,
119-
comments: false,
120-
});
121-
122-
// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
123-
integrationData.scriptCompiled = result?.code ?? undefined;
124-
delete integrationData.scriptError;
125-
} catch (e) {
116+
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
117+
if (error) {
126118
integrationData.scriptCompiled = undefined;
127-
integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined;
119+
integrationData.scriptError = error;
120+
} else {
121+
integrationData.scriptCompiled = script;
122+
delete integrationData.scriptError;
128123
}
129124
}
130125

apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts

Lines changed: 24 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { transformSync } from '@babel/core';
2-
import presetEnv from '@babel/preset-env';
31
import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings';
42
import type { ServerMethods } from '@rocket.chat/ddp-client';
53
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
@@ -9,6 +7,7 @@ import { Meteor } from 'meteor/meteor';
97
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
108
import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
119
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
10+
import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
1211
import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine';
1312

1413
const validChannelChars = ['@', '#'];
@@ -84,49 +83,28 @@ export const updateIncomingIntegration = async (
8483

8584
const isFrozen = isScriptEngineFrozen(scriptEngine);
8685

87-
if (!isFrozen) {
88-
let scriptCompiled: string | undefined;
89-
let scriptError: Pick<Error, 'name' | 'message' | 'stack'> | undefined;
90-
91-
if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
92-
try {
93-
const result = transformSync(integration.script, {
94-
presets: [presetEnv],
95-
compact: true,
96-
minified: true,
97-
comments: false,
98-
});
99-
100-
// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
101-
scriptCompiled = result?.code ?? undefined;
102-
scriptError = undefined;
103-
await Integrations.updateOne(
104-
{ _id: integrationId },
105-
{
106-
$set: {
107-
scriptCompiled,
108-
},
109-
$unset: { scriptError: 1 as const },
110-
},
111-
);
112-
} catch (e) {
113-
scriptCompiled = undefined;
114-
if (e instanceof Error) {
115-
const { name, message, stack } = e;
116-
scriptError = { name, message, stack };
117-
}
118-
await Integrations.updateOne(
119-
{ _id: integrationId },
120-
{
121-
$set: {
122-
scriptError,
123-
},
124-
$unset: {
125-
scriptCompiled: 1 as const,
126-
},
127-
},
128-
);
129-
}
86+
// Default to transpiling with Babel for backwards compatibility; integrations
87+
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
88+
const skipTranspile = integration.skipTranspile === true;
89+
90+
if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
91+
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
92+
if (error) {
93+
await Integrations.updateOne(
94+
{ _id: integrationId },
95+
{
96+
$set: { scriptError: error, skipTranspile },
97+
$unset: { scriptCompiled: 1 as const },
98+
},
99+
);
100+
} else {
101+
await Integrations.updateOne(
102+
{ _id: integrationId },
103+
{
104+
$set: { scriptCompiled: script, skipTranspile },
105+
$unset: { scriptError: 1 as const },
106+
},
107+
);
130108
}
131109
}
132110

@@ -192,6 +170,7 @@ export const updateIncomingIntegration = async (
192170
...(typeof integration.script !== 'undefined' && { script: integration.script }),
193171
scriptEnabled: integration.scriptEnabled,
194172
...(scriptEngine && { scriptEngine }),
173+
skipTranspile,
195174
}),
196175
...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && {
197176
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled,

apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const updateOutgoingIntegration = async (
8787
script: integration.script,
8888
scriptEnabled: integration.scriptEnabled,
8989
scriptEngine,
90+
skipTranspile: integration.skipTranspile,
9091
...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }),
9192
}),
9293
triggerWords: integration.triggerWords,

0 commit comments

Comments
 (0)