Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/integration-script-transpile-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
---

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import vm from 'node:vm';

import { transformSync } from '@babel/core';
import presetEnv from '@babel/preset-env';

/**
* Compile or validate a user-supplied integration script for storage in
* `scriptCompiled`.
*
* When `transpile` is `true` (the default, controlled by each integration's
* `skipTranspile` flag), the script is transpiled with `@babel/core +
* @babel/preset-env` — the historical behavior. When `false`, the script is
* validated with Node's built-in `vm.Script` and stored as-is, matching the
* 9.0.0 default where Babel transpilation is removed entirely.
*
* Integration scripts run inside `isolated-vm`, which embeds modern V8 and
* handles ES2023+ natively. The transpilation only exists to preserve the
* sloppy-mode semantics (implicit globals in class methods, `this` in nested
* functions, etc.) that early scripts relied on. Admins can flip
* `skipTranspile: true` per integration to test strict-mode compatibility
* before the 9.0.0 upgrade.
*
* Returns `{ script }` on success or `{ error }` with the same
* `{ name, message, stack }` shape persisted in `scriptError`.
*/
export function compileIntegrationScript(
script: string,
{ transpile }: { transpile: boolean },
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
if (!transpile) {
return validateOnly(script);
}

return transpileWithBabel(script);
}

function validateOnly(
script: string,
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
try {
new vm.Script(`(function(){${script}})`);
return { script };
} catch (e) {
if (e instanceof SyntaxError) {
const { name, message, stack } = e;
return { error: { name, message, stack } };
}
throw e;
}
}

function transpileWithBabel(
script: string,
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
try {
const result = transformSync(script, {
presets: [presetEnv],
compact: true,
minified: true,
comments: false,
});

return { script: result?.code ?? script };
} catch (e) {
if (e instanceof Error) {
const { name, message, stack } = e;
return { error: { name, message, stack } };
}
throw e;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { transformSync } from '@babel/core';
import presetEnv from '@babel/preset-env';
import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { pick } from '@rocket.chat/tools';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { compileIntegrationScript } from './compileIntegrationScript';
import { isScriptEngineFrozen } from './validateScriptEngine';
import { parseCSV } from '../../../../lib/utils/parseCSV';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission';
Expand Down Expand Up @@ -172,28 +170,20 @@ export const validateOutgoingIntegration = async function (
delete integrationData.triggerWords;
}

// Only compile the script if it is enabled and using a sandbox that is not frozen
// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;
integrationData.skipTranspile = skipTranspile;

if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
try {
const result = transformSync(integration.script, {
presets: [presetEnv],
compact: true,
minified: true,
comments: false,
});

// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
integrationData.scriptCompiled = result?.code ?? undefined;
integrationData.scriptError = undefined;
} catch (e) {
integrationData.scriptCompiled = undefined;
integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined;
}
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
integrationData.scriptCompiled = script;
integrationData.scriptError = error;
}

if (typeof integration.runOnEdits !== 'undefined') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { transformSync } from '@babel/core';
import presetEnv from '@babel/preset-env';
import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { removeEmpty } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';

import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine';

const validChannelChars = ['@', '#'];
Expand Down Expand Up @@ -92,9 +90,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
});
}

// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;

const integrationData: IIncomingIntegration = {
...integration,
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
skipTranspile,
type: 'webhook-incoming',
channel: channels,
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false,
Expand All @@ -104,27 +107,19 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
_createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
};

// Only compile the script if it is enabled and using a sandbox that is not frozen
if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
try {
const result = transformSync(integration.script, {
presets: [presetEnv],
compact: true,
minified: true,
comments: false,
});

// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
integrationData.scriptCompiled = result?.code ?? undefined;
delete integrationData.scriptError;
} catch (e) {
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
if (error) {
integrationData.scriptCompiled = undefined;
integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined;
integrationData.scriptError = error;
} else {
integrationData.scriptCompiled = script;
delete integrationData.scriptError;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { transformSync } from '@babel/core';
import presetEnv from '@babel/preset-env';
import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
Expand All @@ -9,6 +7,7 @@ import { Meteor } from 'meteor/meteor';
import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
import { compileIntegrationScript } from '../../lib/compileIntegrationScript';
import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine';

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

const isFrozen = isScriptEngineFrozen(scriptEngine);

if (!isFrozen) {
let scriptCompiled: string | undefined;
let scriptError: Pick<Error, 'name' | 'message' | 'stack'> | undefined;

if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
try {
const result = transformSync(integration.script, {
presets: [presetEnv],
compact: true,
minified: true,
comments: false,
});

// TODO: Webhook Integration Editor should inform the user if the script is compiled successfully
scriptCompiled = result?.code ?? undefined;
scriptError = undefined;
await Integrations.updateOne(
{ _id: integrationId },
{
$set: {
scriptCompiled,
},
$unset: { scriptError: 1 as const },
},
);
} catch (e) {
scriptCompiled = undefined;
if (e instanceof Error) {
const { name, message, stack } = e;
scriptError = { name, message, stack };
}
await Integrations.updateOne(
{ _id: integrationId },
{
$set: {
scriptError,
},
$unset: {
scriptCompiled: 1 as const,
},
},
);
}
// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;

if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
if (error) {
await Integrations.updateOne(
{ _id: integrationId },
{
$set: { scriptError: error, skipTranspile },
$unset: { scriptCompiled: 1 as const },
},
);
} else {
await Integrations.updateOne(
{ _id: integrationId },
{
$set: { scriptCompiled: script, skipTranspile },
$unset: { scriptError: 1 as const },
},
);
}
}

Expand Down Expand Up @@ -192,6 +170,7 @@ export const updateIncomingIntegration = async (
...(typeof integration.script !== 'undefined' && { script: integration.script }),
scriptEnabled: integration.scriptEnabled,
...(scriptEngine && { scriptEngine }),
skipTranspile,
}),
...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && {
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const updateOutgoingIntegration = async (
script: integration.script,
scriptEnabled: integration.scriptEnabled,
scriptEngine,
skipTranspile: integration.skipTranspile,
...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }),
}),
triggerWords: integration.triggerWords,
Expand Down
Loading
Loading