@casual-simulation/aux-runtime
Version:
Runtime for AUX projects
788 lines • 33.8 kB
JavaScript
/* CasualOS is a set of web-based tools designed to facilitate the creation of real-time, multi-user, context-aware interactive experiences.
*
* Copyright (c) 2019-2025 Casual Simulation, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { calculateFinalLineLocation, calculateOriginalLineLocation, Transpiler, } from './Transpiler';
import { isScript, parseScript, hasValue, isModule, parseModule, } from '@casual-simulation/aux-common/bots';
import ErrorStackParser from '@casual-simulation/error-stack-parser';
import StackFrame from 'stackframe';
import { unwind, INTERPRETER_OBJECT, } from '@casual-simulation/js-interpreter/InterpreterUtils';
/**
* A symbol that identifies a function as having been compiled using the AuxCompiler.
*/
export const COMPILED_SCRIPT_SYMBOL = Symbol('compiled_script');
/**
* The symbol that is used to tag specific functions as interpretable.
*/
export const INTERPRETABLE_FUNCTION = Symbol('interpretable_function');
/**
* The symbol that is used to tag function modules with the metadata for a function.
*/
export const FUNCTION_METADATA = Symbol('function_metadata');
/**
* Creates a new interpretable function based on the given function.
* @param interpretableFunc
*/
export function createInterpretableFunction(interpretableFunc) {
const normalFunc = ((...args) => unwind(interpretableFunc(...args)));
normalFunc[INTERPRETABLE_FUNCTION] = interpretableFunc;
return normalFunc;
}
/**
* Sets the INTERPRETABLE_FUNCTION property on the given object (semantically a function) to the given interpretable version and returns the object.
* @param interpretableFunc The version of the function that should be used as the interpretable version of the function.
* @param normalFunc The function that should be tagged.
*/
export function tagAsInterpretableFunction(interpretableFunc, normalFunc) {
normalFunc[INTERPRETABLE_FUNCTION] = interpretableFunc;
return normalFunc;
}
/**
* Determines if the given object has been tagged with the GENERATOR_FUNCTION_TAG.
* @param obj The object.
*/
export function isInterpretableFunction(obj) {
return ((typeof obj === 'function' || typeof obj === 'object') &&
obj !== null &&
!!obj[INTERPRETABLE_FUNCTION]);
}
/**
* Gets the interpretable version of the given function.
*/
export function getInterpretableFunction(obj) {
return isInterpretableFunction(obj)
? obj[INTERPRETABLE_FUNCTION]
: null;
}
const JSX_FACTORY = 'html.h';
const JSX_FRAGMENT_FACTORY = 'html.f';
export const IMPORT_FACTORY = '___importModule';
export const IMPORT_META_FACTORY = '___importMeta';
export const EXPORT_FACTORY = '___exportModule';
/**
* Defines a class that can compile scripts and formulas
* into functions.
*/
export class AuxCompiler {
constructor() {
this._transpiler = new Transpiler({
jsxFactory: JSX_FACTORY,
jsxFragment: JSX_FRAGMENT_FACTORY,
importFactory: IMPORT_FACTORY,
importMetaFactory: IMPORT_META_FACTORY,
exportFactory: EXPORT_FACTORY,
});
this._functionCache = new Map();
/**
* The offset that should be applied to error line numbers when calculating their original
* position. Needed because Node.js Windows produces different line numbers than Mac/Linux.
*
* Node.js versions greater than v12.14.0 have an issue with identifying the correct line number
* for errors and stack traces. This issue is fixed in Node.js v14 and later (possibly also fixed in v13 but I didn't check that).
*/
this.functionErrorLineOffset = 0;
}
/**
* Calculates the "original" stack trace that the given error occurred at
* within the given function.
* Returns null if the original stack trace was unable to be determined.
* @param functionNameMap A map of function names to their scripts.
* @param error The error that occurred.
*/
calculateOriginalStackTrace(functionNameMap, error) {
if (INTERPRETER_OBJECT in error) {
return this._calculateInterpreterErrorOriginalStackTrace(functionNameMap, error);
}
else {
return this._calculateNativeErrorOriginalStackTrace(functionNameMap, error);
}
}
_calculateInterpreterErrorOriginalStackTrace(functionNameMap, error) {
var _a, _b, _c, _d, _e, _f;
const frames = ErrorStackParser.parse(error);
if (frames.length < 1) {
return null;
}
let transformedFrames = [];
let lastScriptFrameIndex = -1;
let lastScript;
for (let i = frames.length - 1; i >= 0; i--) {
const frame = frames[i];
let savedFrame = false;
let functionName = frame.functionName;
const lastDotIndex = functionName.lastIndexOf('.');
if (lastDotIndex >= 0) {
functionName = functionName.slice(lastDotIndex + 1);
}
const script = functionNameMap.get(functionName);
let isWrapperFunc = false;
if (!/^__wrapperFunc/.test(frame.functionName)) {
if (script) {
lastScript = script;
const location = {
lineNumber: frame.lineNumber + this.functionErrorLineOffset,
column: frame.columnNumber,
};
const originalLocation = this.calculateOriginalLineLocation(script, location);
savedFrame = true;
if (lastScriptFrameIndex < 0) {
lastScriptFrameIndex = i;
}
transformedFrames.unshift(new StackFrame({
functionName: (_a = lastScript.metadata.diagnosticFunctionName) !== null && _a !== void 0 ? _a : functionName,
fileName: (_b = script.metadata.fileName) !== null && _b !== void 0 ? _b : functionName,
lineNumber: Math.max(originalLocation.lineNumber + 1, 1),
columnNumber: Math.max(originalLocation.column + 1, 1),
}));
}
else if (lastScript) {
if (typeof frame.lineNumber === 'number' &&
typeof frame.columnNumber === 'number') {
const location = {
lineNumber: frame.lineNumber + this.functionErrorLineOffset,
column: frame.columnNumber,
};
const originalLocation = this.calculateOriginalLineLocation(lastScript, location);
savedFrame = true;
transformedFrames.unshift(new StackFrame({
functionName: (_c = lastScript.metadata
.diagnosticFunctionName) !== null && _c !== void 0 ? _c : functionName,
fileName: (_d = lastScript.metadata.fileName) !== null && _d !== void 0 ? _d : functionName,
lineNumber: Math.max(originalLocation.lineNumber + 1, 1),
columnNumber: Math.max(originalLocation.column + 1, 1),
}));
}
else {
savedFrame = true;
transformedFrames.unshift(new StackFrame({
functionName: (_e = lastScript.metadata
.diagnosticFunctionName) !== null && _e !== void 0 ? _e : functionName,
fileName: (_f = lastScript.metadata.fileName) !== null && _f !== void 0 ? _f : functionName,
}));
}
}
}
else {
isWrapperFunc = true;
}
if (!savedFrame && isWrapperFunc) {
savedFrame = true;
if (lastScriptFrameIndex > i) {
lastScriptFrameIndex -= 1;
}
}
if (!savedFrame) {
transformedFrames.unshift(frame);
}
}
if (lastScriptFrameIndex >= 0) {
const finalFrames = [
...transformedFrames.slice(0, lastScriptFrameIndex + 1),
new StackFrame({
fileName: '[Native CasualOS Code]',
functionName: '<CasualOS>',
}),
];
const stack = finalFrames
.map((frame) => ' at ' + frame.toString())
.join('\n');
return error.toString() + '\n' + stack;
}
return null;
}
_calculateNativeErrorOriginalStackTrace(functionNameMap, error) {
var _a, _b, _c, _d;
const frames = ErrorStackParser.parse(error);
if (frames.length < 1) {
return null;
}
let transformedFrames = [];
let lastScriptFrameIndex = -1;
let lastScript;
for (let i = frames.length - 1; i >= 0; i--) {
const frame = frames[i];
const originFrame = frame.evalOrigin;
let savedFrame = false;
if (!!originFrame &&
originFrame.functionName === '__constructFunction') {
let functionName = frame.functionName;
const lastDotIndex = functionName.lastIndexOf('.');
if (lastDotIndex >= 0) {
functionName = functionName.slice(lastDotIndex + 1);
}
const script = functionNameMap.get(functionName);
if (script) {
lastScript = script;
const location = {
lineNumber: originFrame.lineNumber +
this.functionErrorLineOffset,
column: originFrame.columnNumber,
};
const originalLocation = this.calculateOriginalLineLocation(script, location);
savedFrame = true;
if (lastScriptFrameIndex < 0) {
lastScriptFrameIndex = i;
}
transformedFrames.unshift(new StackFrame({
functionName: (_a = lastScript.metadata.diagnosticFunctionName) !== null && _a !== void 0 ? _a : functionName,
fileName: (_b = script.metadata.fileName) !== null && _b !== void 0 ? _b : functionName,
lineNumber: originalLocation.lineNumber + 1,
columnNumber: originalLocation.column + 1,
}));
}
else if (lastScript) {
const location = {
lineNumber: originFrame.lineNumber +
this.functionErrorLineOffset,
column: originFrame.columnNumber,
};
const originalLocation = this.calculateOriginalLineLocation(lastScript, location);
savedFrame = true;
transformedFrames.unshift(new StackFrame({
functionName: (_c = lastScript.metadata.diagnosticFunctionName) !== null && _c !== void 0 ? _c : functionName,
fileName: (_d = lastScript.metadata.fileName) !== null && _d !== void 0 ? _d : functionName,
lineNumber: originalLocation.lineNumber + 1,
columnNumber: originalLocation.column + 1,
}));
}
}
if (!savedFrame) {
if (frame.functionName === '__wrapperFunc') {
savedFrame = true;
if (lastScriptFrameIndex > i) {
lastScriptFrameIndex -= 1;
}
}
}
if (!savedFrame) {
transformedFrames.unshift(frame);
}
}
if (lastScriptFrameIndex >= 0) {
const finalFrames = [
...transformedFrames.slice(0, lastScriptFrameIndex + 1),
new StackFrame({
fileName: '[Native CasualOS Code]',
functionName: '<CasualOS>',
}),
];
const stack = finalFrames
.map((frame) => ' at ' + frame.toString())
.join('\n');
return error.toString() + '\n' + stack;
}
return null;
}
/**
* Calculates the original location within the given function for the given location.
* The returned location uses zero-based line and column numbers.
* @param func The function.
* @param location The location. Line and column numbers are one-based.
*/
calculateOriginalLineLocation(func, location) {
// Line numbers should be one based
if (location.lineNumber <
func.metadata.scriptLineOffset + func.metadata.transpilerLineOffset) {
return {
lineNumber: 0,
column: 0,
};
}
let transpiledLocation = {
lineNumber: location.lineNumber -
func.metadata.scriptLineOffset -
func.metadata.transpilerLineOffset -
1,
column: location.column - 1,
};
let result = calculateOriginalLineLocation(func.metadata.transpilerResult, transpiledLocation);
return {
lineNumber: result.lineNumber,
column: result.column,
};
}
/**
* Calculates the final location within the given function for the given location.
* @param func The function.
* @param location The location. Line and column numbers are zero based.
*/
calculateFinalLineLocation(func, location) {
// Line numbers should be zero based
if (location.lineNumber < 0) {
return {
lineNumber: 0,
column: 0,
};
}
let transpiledLocation = {
lineNumber: location.lineNumber,
column: location.column,
};
let result = calculateFinalLineLocation(func.metadata.transpilerResult, transpiledLocation);
return {
lineNumber: result.lineNumber +
func.metadata.scriptLineOffset +
func.metadata.transpilerLineOffset,
column: result.column,
};
}
/**
* Compiles the given script into a function.
* @param script The script to compile.
* @param options The options that should be used to compile the script.
*/
compile(script, options) {
var _a;
let { func, scriptLineOffset, transpilerLineOffset, async, transpilerResult, constructedFunction, } = this._compileFunction(script, options || {});
const scriptFunction = func;
const meta = {
scriptFunction,
scriptLineOffset,
transpilerLineOffset,
transpilerResult,
fileName: options === null || options === void 0 ? void 0 : options.fileName,
diagnosticFunctionName: options === null || options === void 0 ? void 0 : options.diagnosticFunctionName,
isAsync: async,
constructedFunction,
context: options === null || options === void 0 ? void 0 : options.context,
isModule: transpilerResult.metadata.isModule,
};
if (options) {
if (options.before ||
options.after ||
options.onError ||
options.invoke ||
async) {
const before = options.before || (() => { });
const after = options.after || (() => { });
const onError = options.onError ||
((err) => {
throw err;
});
const invoke = options.invoke;
const context = options.context;
const scriptFunc = func;
const finalFunc = invoke
? (...args) => invoke(() => scriptFunc(...args), context)
: scriptFunc;
if (async) {
func = function __wrapperFunc(...args) {
before(context);
try {
let result = finalFunc(...args);
if (!(result instanceof Promise)) {
result = new Promise((resolve, reject) => {
result.then(resolve, reject);
});
}
return result.catch((ex) => {
onError(ex, context, meta);
});
}
catch (ex) {
onError(ex, context, meta);
}
finally {
after(context);
}
};
if (isInterpretableFunction(scriptFunc)) {
const interpretableFunc = getInterpretableFunction(scriptFunc);
const finalFunc = invoke
? (...args) => invoke(() => interpretableFunc(...args), context)
: interpretableFunc;
const interpretable = function* __wrapperFunc(...args) {
before(context);
try {
let result = yield* finalFunc(...args);
if (!(result instanceof Promise)) {
result = new Promise((resolve, reject) => {
result.then(resolve, reject);
});
}
return result.catch((ex) => {
onError(ex, context, meta);
});
}
catch (ex) {
onError(ex, context, meta);
}
finally {
after(context);
}
};
tagAsInterpretableFunction(interpretable, func);
}
}
else {
func = function __wrapperFunc(...args) {
before(context);
try {
return finalFunc(...args);
}
catch (ex) {
onError(ex, context, meta);
}
finally {
after(context);
}
};
if (isInterpretableFunction(scriptFunc)) {
const interpretableFunc = getInterpretableFunction(scriptFunc);
const finalFunc = invoke
? (...args) => invoke(() => interpretableFunc(...args), context)
: interpretableFunc;
const interpretable = function* __wrapperFunc(...args) {
before(context);
try {
return yield* finalFunc(...args);
}
catch (ex) {
onError(ex, context, meta);
}
finally {
after(context);
}
};
tagAsInterpretableFunction(interpretable, func);
}
}
}
}
const final = func;
final.metadata = meta;
if ((_a = meta.constructedFunction) === null || _a === void 0 ? void 0 : _a.module) {
Object.defineProperty(meta.constructedFunction.module, FUNCTION_METADATA, {
value: meta,
writable: false,
enumerable: false,
configurable: true,
});
}
return final;
}
/**
* Finds the line number information for a function created with this compiler using the
* given stack trace and metadata.
* @param stackTrace The stack trace.
* @param metadata The metadata.
*/
findLineInfo(stackTrace, metadata) {
const frame = stackTrace.find((f) => {
const func = f.getFunction();
return func && func[COMPILED_SCRIPT_SYMBOL] === true;
});
if (frame) {
const line = frame.getLineNumber();
const column = frame.getColumnNumber();
const result = {
line: null,
column: null,
};
if (hasValue(line)) {
result.line = line - metadata.scriptLineOffset;
}
if (hasValue(column)) {
result.column = column;
}
return result;
}
return null;
}
/**
* Sets the given breakpoint.
* @param breakpoint The breakpoint that should be set.
*/
setBreakpoint(breakpoint) {
const metadata = breakpoint.func.metadata;
if (!metadata.constructedFunction) {
throw new Error('Cannot set breakpoints for non-interpreted functions.');
}
if (!breakpoint.interpreter) {
throw new Error('You must provide an interpreter when setting a breakpoint.');
}
const func = metadata.constructedFunction;
const interpreter = breakpoint.interpreter;
const loc = this.calculateFinalLineLocation(breakpoint.func, {
lineNumber: breakpoint.lineNumber - 1,
column: breakpoint.columnNumber - 1,
});
interpreter.setBreakpoint({
id: breakpoint.id,
func,
lineNumber: loc.lineNumber + 1,
columnNumber: loc.column + 1,
states: breakpoint.states,
});
}
listPossibleBreakpoints(func, interpreter) {
const metadata = func.metadata;
if (!metadata.constructedFunction) {
throw new Error('Cannot list possible breakpoints for non-interpreted functions.');
}
if (!interpreter) {
throw new Error('You must provide an interpreter when listing possible breakpoints.');
}
const code = metadata.constructedFunction.func
.ECMAScriptCode;
const returnStatement = code.FunctionStatementList.find((s) => s.type === 'ReturnStatement');
const functionDeclaration = returnStatement.Expression;
const body = 'FunctionBody' in functionDeclaration
? functionDeclaration.FunctionBody
: functionDeclaration.AsyncFunctionBody;
const possibleBreakpoints = interpreter.listPossibleBreakpoints(body);
let returnedValues = [];
for (let pb of possibleBreakpoints) {
if (pb.lineNumber <
metadata.scriptLineOffset + metadata.transpilerLineOffset) {
continue;
}
const loc = this.calculateOriginalLineLocation(func, {
lineNumber: pb.lineNumber,
column: pb.columnNumber,
});
if (loc.lineNumber < 0 || loc.column < 0) {
continue;
}
returnedValues.push({
lineNumber: loc.lineNumber + 1,
columnNumber: loc.column + 1,
possibleStates: pb.possibleStates,
});
}
return returnedValues;
}
_parseScript(script) {
script = parseScript(script);
return this._transpiler.transpileWithMetadata(script);
}
_parseModule(script) {
script = parseModule(script);
return this._transpiler.transpileWithMetadata(script);
}
_compileFunction(script, options) {
// Yes this code is super ugly.
// Some day we will engineer this into a real
// compiler, but for now this ad-hoc method
// seems to work.
var _a, _b;
this._transpiler.forceSync = (_a = options.forceSync) !== null && _a !== void 0 ? _a : false;
let async = false;
let transpiled;
let transpilerLineOffset = 0;
let scriptLineOffset = 0;
let syntaxErrorLineOffset = 0;
try {
if (isScript(script)) {
transpiled = this._parseScript(script);
}
else if (isModule(script)) {
transpiled = this._parseModule(script);
}
else {
transpiled = this._transpiler.transpileWithMetadata(script);
}
}
catch (err) {
if (err instanceof SyntaxError) {
const replaced = replaceSyntaxErrorLineNumber(err, (location) => ({
lineNumber: location.lineNumber -
transpilerLineOffset -
syntaxErrorLineOffset,
column: location.column,
}));
if (replaced) {
throw replaced;
}
}
throw err;
}
if (transpiled.metadata.isModule) {
// All modules are async
async = true;
}
else if (transpiled.metadata.isAsync) {
async = true;
}
if (options.forceSync) {
async = false;
}
let customGlobalThis = false;
let constantsCode = '';
if (options.constants) {
const lines = Object.keys(options.constants)
.filter((v) => v !== 'this')
.map((v) => `const ${v} = constants["${v}"];`);
customGlobalThis =
!options.interpreter && 'globalThis' in options.constants;
constantsCode = lines.join('\n') + '\n';
scriptLineOffset += 1 + Math.max(lines.length - 1, 0);
}
let variablesCode = '';
if (options.variables) {
const lines = Object.keys(options.variables)
.filter((v) => v !== 'this')
.map((v) => `const ${v} = variables["${v}"](context);`);
variablesCode = '\n' + lines.join('\n');
transpilerLineOffset += 1 + Math.max(lines.length - 1, 0);
}
let argumentsCode = '';
if (options.arguments) {
const lines = options.arguments
.filter((v) => v !== 'this')
.map((v, i) => Array.isArray(v)
? [v, i]
: [[v], i])
.flatMap(([v, i]) => v.map((name) => {
var _a;
const defaultName = `_${name}`;
if ((_a = options.variables) === null || _a === void 0 ? void 0 : _a[defaultName]) {
return `const ${name} = typeof args[${i}] === 'undefined' ? variables?.["_${name}"]?.(context) : args[${i}];`;
}
return `const ${name} = args[${i}];`;
}));
argumentsCode = '\n' + lines.join('\n');
transpilerLineOffset += 1 + Math.max(lines.length - 1, 0);
}
let scriptCode;
scriptCode = `\n { \n${transpiled.code}\n }`;
transpilerLineOffset += 2;
let withCodeStart = '';
let withCodeEnd = '';
if (customGlobalThis) {
withCodeStart = 'with(__globalObj) {\n';
withCodeEnd = '}';
scriptLineOffset += 1;
}
// Function needs a name because acorn doesn't understand
// that this function is allowed to be anonymous.
let functionCode = `function ${(_b = options.functionName) !== null && _b !== void 0 ? _b : '_'}(...args) { ${argumentsCode}${variablesCode}${scriptCode}\n }`;
if (async) {
functionCode = `async ` + functionCode;
}
try {
if (options.interpreter) {
const finalCode = `${constantsCode}return ${functionCode};`;
syntaxErrorLineOffset += 1;
scriptLineOffset += 1;
const func = options.interpreter.createFunction('test', finalCode, 'constants', 'variables', 'context');
let result = unwind(options.interpreter.callFunction(func, options.constants, options.variables, options.interpreter.proxyObject(options.context)));
let finalFunc = result;
if (options.variables) {
if ('this' in options.variables) {
finalFunc = result.bind(options.variables['this'](options.context));
}
}
if (INTERPRETER_OBJECT in result) {
finalFunc = createInterpretableFunction(finalFunc);
finalFunc[INTERPRETER_OBJECT] = result[INTERPRETER_OBJECT];
}
return {
func: finalFunc,
scriptLineOffset,
transpilerLineOffset,
async,
transpilerResult: transpiled,
constructedFunction: func,
};
}
else {
const finalCode = `${withCodeStart}return function(constants, variables, context) { "use strict"; ${constantsCode}return ${functionCode}; }${withCodeEnd}`;
let func = this._buildFunction(finalCode, options);
func[COMPILED_SCRIPT_SYMBOL] = true;
// Add 1 extra line to count the line feeds that
// is automatically inserted at the start of the script as part of the process of
// compiling the dynamic script.
// See https://tc39.es/ecma262/#sec-createdynamicfunction
scriptLineOffset += 2;
if (options.variables) {
if ('this' in options.variables) {
func = func.bind(options.variables['this'](options.context));
}
}
return {
func,
scriptLineOffset: scriptLineOffset,
transpilerLineOffset: transpilerLineOffset,
async,
transpilerResult: transpiled,
constructedFunction: null,
};
}
}
catch (err) {
if (err instanceof SyntaxError) {
const replaced = replaceSyntaxErrorLineNumber(err, (location) => ({
lineNumber: location.lineNumber -
transpilerLineOffset -
syntaxErrorLineOffset,
column: location.column,
}));
if (replaced) {
throw replaced;
}
}
throw err;
}
}
_buildFunction(finalCode, options) {
var _a;
return this.__constructFunction(finalCode)((_a = options.constants) === null || _a === void 0 ? void 0 : _a.globalThis)(options.constants, options.variables, options.context);
}
__constructFunction(finalCode) {
let existing = this._functionCache.get(finalCode);
if (!existing) {
existing = Function('__globalObj', finalCode);
this._functionCache.set(finalCode, existing);
}
return existing;
}
}
const SYNTAX_ERROR_LINE_NUMBER_REGEX = /\((\d+):(\d+)\)$/;
/**
* Parses the line and column numbers from the the given syntax error, transforms them with the given function,
* and returns a new syntax error that contains the new location. Returns null if the line and column numbers could not be parsed.
* @param error The error to transform.
* @param transform The function that should be used to transform the errors.
* @returns
*/
export function replaceSyntaxErrorLineNumber(error, transform) {
const matches = SYNTAX_ERROR_LINE_NUMBER_REGEX.exec(error.message);
if (matches) {
const [str, line, column] = matches;
const lineNumber = parseInt(line);
const columnNumber = parseInt(column);
const location = transform({
lineNumber,
column: columnNumber,
});
return new SyntaxError(error.message.replace(str, `(${location.lineNumber}:${location.column})`));
}
else {
return null;
}
}
// export class CompiledScriptError extends Error {
// /**
// * The inner error.
// */
// error: Error;
// constructor(error: Error) {
// super(error.message);
// this.error = error;
// }
// }
//# sourceMappingURL=AuxCompiler.js.map