n8n/packages/@n8n/expression-runtime/src/runtime/context.ts

505 lines
18 KiB
TypeScript

import { DateTime, IANAZone, Settings } from 'luxon';
import { extend, extendOptional } from '../extensions/extend';
import { extendedFunctions } from '../extensions/function-extensions';
import { __sanitize, createSafeErrorSubclass, ExpressionError } from './safe-globals';
import {
createDeepLazyProxy,
isArrayMetadata,
isObjectMetadata,
throwIfErrorSentinel,
} from './lazy-proxy';
import { jmesPath } from './jmespath';
import { isKeyOf } from './utils';
import type { BridgeMessage } from '../bridge/bridge-messages';
// Pre-create safe error subclass wrappers (reused across evaluations)
const SafeTypeError = createSafeErrorSubclass(TypeError);
const SafeSyntaxError = createSafeErrorSubclass(SyntaxError);
const SafeEvalError = createSafeErrorSubclass(EvalError);
const SafeRangeError = createSafeErrorSubclass(RangeError);
const SafeReferenceError = createSafeErrorSubclass(ReferenceError);
const SafeURIError = createSafeErrorSubclass(URIError);
/**
* Maps proxy property names on `$('NodeName')` to the typed-RPC discriminator
* the bridge dispatches on. Single source of truth for the synthetic proxy's
* `get`/`has` traps and the `sendNodeMethod` envelope builder. Adding a new
* `getNode*` schema to `BridgeMessage` lets you wire a new method here in
* one place; `satisfies` catches typos in the discriminator string.
*/
const NODE_RPC_TYPES = {
first: 'getNodeFirst',
last: 'getNodeLast',
all: 'getNodeAll',
} as const satisfies Record<string, BridgeMessage['type']>;
type NodeRpcType = (typeof NODE_RPC_TYPES)[keyof typeof NODE_RPC_TYPES];
/**
* Same shape as `NODE_RPC_TYPES`, for the current node's `$input` proxy.
* Discriminators are `getInput*` and the host enforces zero-arg invocation.
*/
const INPUT_RPC_TYPES = {
first: 'getInputFirst',
last: 'getInputLast',
all: 'getInputAll',
} as const satisfies Record<string, BridgeMessage['type']>;
type InputRpcType = (typeof INPUT_RPC_TYPES)[keyof typeof INPUT_RPC_TYPES];
// ============================================================================
// Build Context Function
// ============================================================================
/**
* The subset of `ivm.Reference` shape the in-isolate runtime relies on.
* Declared locally rather than importing from `isolated-vm` because this
* module is bundled into the isolate IIFE, where the native module is
* unavailable. The host wires real `ivm.Reference` instances which
* structurally satisfy this interface.
*/
interface BridgeCallback {
applySync(
thisArg: unknown,
args: unknown[],
options?: { arguments?: { copy?: boolean }; result?: { copy?: boolean } },
): unknown;
}
/**
* Bridge callbacks the in-isolate runtime can invoke synchronously via
* `ivm.Reference.applySync`.
*
* - `getValueAtPath`, `getArrayElement`: data-access primitives used by the
* lazy-proxy system. Hot path; one ivm.Reference each for minimum overhead.
* - `callFunctionAtPath`: legacy generic dispatch; to be removed once
* every consumer has migrated to typed messages.
* - `callHost`: typed-RPC dispatcher. The in-isolate runtime constructs
* an envelope (e.g. `{ type: 'getNodeFirst', nodeName, ... }`) and the
* host-side dispatcher validates it with zod before routing to a handler.
* A single ivm.Reference covers every typed operation; new operations
* are new schemas in `bridge/bridge-messages.ts` + new cases in the
* dispatcher switch. The name reflects what this is: a synchronous
* host RPC, not a postMessage-style async send.
*
* The bridge wires all four callbacks unconditionally before invoking
* `buildContext`, so the runtime treats them as present — no defensive
* null/undefined checks at each call site.
*/
export interface BridgeCallbacks {
getValueAtPath: BridgeCallback;
getArrayElement: BridgeCallback;
callFunctionAtPath: BridgeCallback;
callHost: BridgeCallback;
}
/**
* Build a fresh, closure-scoped evaluation context.
*
* This function creates a context object that contains everything needed to
* evaluate an expression, without touching any global mutable state
* (except luxon's Settings.defaultZone which is process-wide).
*
* The returned object is used as tournament's `this` context in the
* evalClosureSync wrapper.
*
* @param callbacks - Bridge callbacks (data-access primitives + typed RPCs)
* @param timezone - Optional IANA timezone string
* @returns A context object with all workflow data, proxies, and builtins
*/
export function buildContext(
callbacks: BridgeCallbacks,
timezone?: string,
): Record<string, unknown> {
if (timezone && !IANAZone.isValidZone(timezone)) {
throw new Error(`Invalid timezone: "${timezone}"`);
}
Settings.defaultZone = timezone ?? 'system';
const target: Record<string, unknown> = {};
// __sanitize must be on the context because PrototypeSanitizer generates:
// obj[this.__sanitize(expr)] where 'this' is the context (via .call(ctx) wrapping)
// Use a non-writable property descriptor so override attempts throw instead of silently succeeding.
Object.defineProperty(target, '__sanitize', {
get: () => __sanitize,
set: () => {
throw new ExpressionError('Cannot override "__sanitize" due to security concerns');
},
enumerable: false,
configurable: false,
});
// -------------------------------------------------------------------------
// Create DateTime values inside the isolate (not lazy-loaded from host,
// because host-side DateTime objects lose their prototype crossing the
// boundary). The isolate has its own luxon with the correct timezone
// already set via Settings.defaultZone above.
// -------------------------------------------------------------------------
target.$now = DateTime.now();
target.$today = DateTime.now().set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
// -------------------------------------------------------------------------
// Expose standalone functions (min, max, average, numberList, zip, $ifEmpty, etc.)
// -------------------------------------------------------------------------
Object.assign(target, extendedFunctions);
// -------------------------------------------------------------------------
// Expose globals on target so tournament's "x in this ? this.x : global.x"
// pattern resolves them correctly (tournament checks ctx before global)
// -------------------------------------------------------------------------
target.DateTime = globalThis.DateTime;
target.Duration = globalThis.Duration;
target.Interval = globalThis.Interval;
// Expose extend/extendOptional on target so tournament's "x in this ? this.x : global.x"
// pattern resolves them correctly when the VM checks ctx first
target.extend = extend;
target.extendOptional = extendOptional;
// Expose jmespath helpers in-isolate so they shadow the host-side
// `data.$jmesPath` / `data.$jmespath` from WorkflowDataProxy. Same rationale
// as extend / extendOptional above: keeping these in-isolate removes them
// from the bridge's reachable host-callable surface.
target.$jmesPath = jmesPath;
target.$jmespath = jmesPath;
// Wire builtins so tournament's VariablePolyfill resolves them from ctx
initializeBuiltins(target);
// $item(itemIndex) returns a sub-proxy for the specified item (legacy syntax)
target.$item = function (itemIndex: number) {
const indexStr = String(itemIndex);
return {
$json: createDeepLazyProxy(['$item', indexStr, '$json'], undefined, callbacks),
$binary: createDeepLazyProxy(['$item', indexStr, '$binary'], undefined, callbacks),
};
};
// $() function for accessing other nodes.
//
// The returned object is a Proxy whose `get` trap intercepts properties
// that have a typed RPC (e.g. `.first` → `getNodeFirst`) and routes them
// through the `callHost` envelope. Everything else (properties like
// `.params`, `.json`, and methods that don't yet have a typed RPC) is
// read from an underlying lazy proxy via explicit delegation.
//
// Important: the synthetic Proxy's *target* is a plain `{}` rather than
// the lazy proxy itself. Nesting one Proxy inside another causes V8 to
// run invariant checks (`[[OwnPropertyKeys]]`, descriptor consistency)
// against the inner target, which would trigger the lazy proxy's
// `ownKeys` trap and an unnecessary `getValueAtPath` round-trip for the
// whole node's keys. Using `{}` as the target keeps those checks cheap;
// the lazy proxy lives in closure and is only consulted on demand.
//
// As more typed RPCs are added, more cases land in this trap.
// The `has` trap mirrors the `get` trap for typed-RPC names so that
// tournament's `"first" in this.$('Foo')` check resolves true even though
// the inner target is empty.
target.$ = function (nodeName: string) {
const lazyProxy = createDeepLazyProxy(['$', nodeName], undefined, callbacks);
const sendNodeMethod = (type: NodeRpcType) => {
return (branchIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type, nodeName, branchIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
throwIfErrorSentinel(result);
return result;
};
};
return new Proxy({} as Record<string, unknown>, {
get(_emptyTarget, prop) {
if (isKeyOf(NODE_RPC_TYPES, prop)) {
return sendNodeMethod(NODE_RPC_TYPES[prop]);
}
// Everything else: delegate to the lazy proxy. The lazy proxy's
// own `get` trap handles caching, host fetching, and metadata.
return lazyProxy[prop];
},
has(_emptyTarget, prop) {
return isKeyOf(NODE_RPC_TYPES, prop) || prop in lazyProxy;
},
});
};
// $input — current-node input proxy. Same synthetic-Proxy pattern as
// `target.$()`: intercept the typed-RPC method names (`first`, `last`,
// `all`, all zero-arg per the host's `WorkflowDataProxy`), delegate
// everything else (notably the `.item` getter and `.params` / `.context`
// properties) to a lazy proxy on `$input`.
const lazyInputProxy = createDeepLazyProxy(['$input'], undefined, callbacks);
const sendInputMethod = (type: InputRpcType) => {
return () => {
const result = callbacks.callHost.applySync(null, [{ type }], {
arguments: { copy: true },
result: { copy: true },
});
throwIfErrorSentinel(result);
return result;
};
};
target.$input = new Proxy({} as Record<string, unknown>, {
get(_emptyTarget, prop) {
if (isKeyOf(INPUT_RPC_TYPES, prop)) return sendInputMethod(INPUT_RPC_TYPES[prop]);
return lazyInputProxy[prop];
},
has(_emptyTarget, prop) {
return isKeyOf(INPUT_RPC_TYPES, prop) || prop in lazyInputProxy;
},
});
// $items — global accessor for a node's execution data. Unlike $() and
// $input this is a plain typed-RPC function (not a synthetic Proxy):
// the host enforces nothing structural here, the schema validates the
// args, and the host's `WorkflowDataProxy.$items` applies its own
// defaults when fields are undefined.
target.$items = (nodeName?: string, outputIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'getItems', nodeName, outputIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
throwIfErrorSentinel(result);
return result;
};
// $fromAI / $fromAi / $fromai — AI-builder placeholder accessor.
// All three host aliases route to the same `handleFromAi` callback;
// the typed-RPC envelope is identical regardless of which name the
// expression used. `name` is forwarded as-is — host validates it
// (required, regex-restricted) and emits a structured `ExpressionError`
// on bad input.
const sendFromAi = (
name?: string,
description?: string,
valueType?: string,
defaultValue?: unknown,
) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'fromAi', name, description, valueType, defaultValue }],
{ arguments: { copy: true }, result: { copy: true } },
);
throwIfErrorSentinel(result);
return result;
};
target.$fromAI = sendFromAi;
target.$fromAi = sendFromAi;
target.$fromai = sendFromAi;
// -------------------------------------------------------------------------
// Resolve an unknown key from the host. Called by the proxy's has/get traps
// for keys not already on the target. The resolved value is cached on target
// so each key is fetched at most once per evaluation.
// -------------------------------------------------------------------------
// Track keys we've already probed so we never call applySync twice
// for the same key — even if the host returned undefined.
const probedKeys = new Set<string>();
function resolveFromHost(key: string): boolean {
if (probedKeys.has(key)) return false;
let value: unknown;
try {
value = callbacks.getValueAtPath.applySync(null, [[key]], {
arguments: { copy: true },
result: { copy: true },
});
} catch {
// Don't mark as probed — the throw may be transient
// (e.g. host data not yet available) and a retry should be allowed.
return false;
}
// Mark as probed only after a definitive answer from the host.
probedKeys.add(key);
if (value === undefined) return false;
throwIfErrorSentinel(value);
// Function metadata — create a callable wrapper
if (value && typeof value === 'object' && (value as any).__isFunction) {
target[key] = function (...args: unknown[]) {
const result = callbacks.callFunctionAtPath.applySync(null, [[key], ...args], {
arguments: { copy: true },
result: { copy: true },
});
throwIfErrorSentinel(result);
return result;
};
return true;
}
// Object / array metadata — create a shape-matched lazy proxy for deep access
if (isArrayMetadata(value)) {
target[key] = createDeepLazyProxy(
[key],
{ kind: 'array', length: value.__length },
callbacks,
);
return true;
}
if (isObjectMetadata(value)) {
target[key] = createDeepLazyProxy([key], { kind: 'object', keys: value.__keys }, callbacks);
return true;
}
// Primitive or null — store directly
target[key] = value;
return true;
}
// -------------------------------------------------------------------------
// Wrap target in a Proxy so unknown keys are resolved lazily from the host.
// Tournament's VariablePolyfill transforms identifiers to:
// ("x" in this ? this : global).x
// The has trap intercepts the "in" check, resolves the key on demand, and
// caches it on target. The get trap then finds it cached.
// -------------------------------------------------------------------------
return new Proxy(target, {
has(_target, prop) {
if (typeof prop === 'symbol') return prop in target;
if (prop in target) return true;
return resolveFromHost(prop);
},
get(_target, prop) {
if (typeof prop === 'symbol') return undefined;
if (prop in target) return target[prop as string];
// has() should have resolved it, but handle direct get() too
resolveFromHost(prop as string);
return target[prop as string];
},
});
}
// Matches initializeGlobalContext() lines 262-318 in packages/workflow/src/expression.ts
const DENYLISTED_GLOBALS = [
'document',
'global',
'window',
'Window',
'globalThis',
'self',
'alert',
'prompt',
'confirm',
'eval',
'uneval',
'setTimeout',
'setInterval',
'setImmediate',
'clearImmediate',
'queueMicrotask',
'Function',
'require',
'module',
'Buffer',
'__dirname',
'__filename',
'fetch',
'XMLHttpRequest',
'Promise',
'Generator',
'GeneratorFunction',
'AsyncFunction',
'AsyncGenerator',
'AsyncGeneratorFunction',
'WebAssembly',
'Reflect',
'Proxy',
'escape',
'unescape',
] as const;
/**
* Wire builtins onto a context object so tournament's VariablePolyfill resolves them.
*
* Tournament transforms `Object` → `("Object" in this ? this : window).Object`.
* `this` = ctx. Without these entries on ctx, builtins fall through to
* `window` which doesn't exist in the isolate, causing a ReferenceError.
*
* Mirrors Expression.initializeGlobalContext() in packages/workflow/src/expression.ts.
*/
function initializeBuiltins(data: Record<string, unknown>): void {
// ── Denylist: dangerous globals → empty objects ──
for (const key of DENYLISTED_GLOBALS) {
data[key] = {};
}
data.__lookupGetter__ = undefined;
data.__lookupSetter__ = undefined;
data.__defineGetter__ = undefined;
data.__defineSetter__ = undefined;
// ── Allowlist: safe versions of builtins ──
// Object — use SafeObject wrapper from the runtime bundle
data.Object = globalThis.SafeObject;
// Error types — use SafeError and safe subclass wrappers
data.Error = globalThis.SafeError;
data.TypeError = SafeTypeError;
data.SyntaxError = SafeSyntaxError;
data.EvalError = SafeEvalError;
data.RangeError = SafeRangeError;
data.ReferenceError = SafeReferenceError;
data.URIError = SafeURIError;
// Arrays
data.Array = Array;
data.Int8Array = Int8Array;
data.Uint8Array = Uint8Array;
data.Uint8ClampedArray = Uint8ClampedArray;
data.Int16Array = Int16Array;
data.Uint16Array = Uint16Array;
data.Int32Array = Int32Array;
data.Uint32Array = Uint32Array;
data.Float32Array = Float32Array;
data.Float64Array = Float64Array;
data.BigInt64Array = typeof BigInt64Array !== 'undefined' ? BigInt64Array : {};
data.BigUint64Array = typeof BigUint64Array !== 'undefined' ? BigUint64Array : {};
// Collections
data.Map = Map;
data.WeakMap = WeakMap;
data.Set = Set;
data.WeakSet = WeakSet;
// Internationalization
data.Intl = typeof Intl !== 'undefined' ? Intl : {};
// Text
data.String = String;
// Numbers
data.Number = Number;
data.BigInt = typeof BigInt !== 'undefined' ? BigInt : {};
data.Infinity = Infinity;
data.parseFloat = parseFloat;
data.parseInt = parseInt;
// Structured data
data.JSON = JSON;
data.ArrayBuffer = typeof ArrayBuffer !== 'undefined' ? ArrayBuffer : {};
data.SharedArrayBuffer = typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : {};
data.Atomics = typeof Atomics !== 'undefined' ? Atomics : {};
data.DataView = typeof DataView !== 'undefined' ? DataView : {};
// Encoding
data.encodeURI = encodeURI;
data.encodeURIComponent = encodeURIComponent;
data.decodeURI = decodeURI;
data.decodeURIComponent = decodeURIComponent;
// Other
data.Boolean = Boolean;
data.Symbol = Symbol;
}