/** * Can't use logger in this file, because it runs in the worker. */ import AnsiToHtml from 'ansi-to-html'; import { fork } from 'child_process'; import { writeFile } from 'fs-extra'; import _ from 'lodash'; import type { Subscriber } from 'rxjs'; import type { ITiddlyWiki } from 'tiddlywiki'; import vm, { Context } from 'vm'; import { IZxWorkerMessage, ZxWorkerControlActions } from '../../interface'; import { fixZxPath } from './fixZxPath'; import { addVariableIOToContentAPIVersion, addVariableIOToContentLogVersion, extractVariablesFromExecutionLog, IVariableContext, type IVariableContextList, VARIABLES_MAP_LOG_PREFIX, } from './passVariableBetweenContext'; export * from './passVariableBetweenContext'; export async function executeScriptInZxScriptContext( paths: { filePathToExecute: string; zxPath: string }, observer: Subscriber, content: string, variableContextList: IVariableContextList, index: number, ): Promise; export async function executeScriptInZxScriptContext( paths: { filePathToExecute: string; zxPath: string }, observer: Subscriber, ): Promise; export async function executeScriptInZxScriptContext( paths: { filePathToExecute: string; zxPath: string }, observer: Subscriber, content?: string, variableContextList?: IVariableContextList, index?: number, ): Promise { const { filePathToExecute, zxPath } = paths; try { let contextWithVariableExtraction = ''; if (content !== undefined) { const previousVariableContext = variableContextList?.[(index ?? 0) - 1]; contextWithVariableExtraction = addVariableIOToContentLogVersion(content, previousVariableContext); await writeFile(filePathToExecute, fixZxPath(contextWithVariableExtraction)); } const execution = fork(zxPath, [filePathToExecute], { silent: true }); const ansiConverter = new AnsiToHtml(); await new Promise((resolve, reject) => { execution.on('close', function(code) { observer.next({ type: 'control', actions: ZxWorkerControlActions.ended, message: `child process exited with code ${String(code)}` }); resolve(); }); /** * zx script may have multiple console.log, we need to extract some of them and execute them in the $tw context (`executeScriptInTWContext`). And send some of them back to frontend. */ execution.stdout?.on('data', (stdout: Buffer) => { const message = ansiConverter.toHtml(String(stdout)); if (message.startsWith(VARIABLES_MAP_LOG_PREFIX)) { // deserialize variables from zx script's context, and prepare to execute them in the next $tw context. const variables = extractVariablesFromExecutionLog(message); variableContextList?.push(variables); // this message is very long, and not human-readable, should not send back to frontend. return; } // send back to frontend. observer.next({ type: 'stdout', message }); }); execution.stderr?.on('data', (stderr: Buffer) => { observer.next({ type: 'stderr', message: ansiConverter.toHtml(String(stderr)) }); }); execution.on('error', (error) => { const message = `${error.message} ${error.stack ?? ''}\n${contextWithVariableExtraction}`; observer.next({ type: 'stderr', message }); reject(new Error(message)); }); }); } catch (error) { const message = `zx script's executeZxScriptIIFE() failed with error ${(error as Error).message} ${(error as Error).stack ?? ''}`; observer.next({ type: 'control', actions: ZxWorkerControlActions.error, message }); } } /** * Execute script in $tw context. Using nodejs's vm module, through it is slower than eval, but it allows us define global variables like `console` and `$tw`. * @param scriptContent string to eval * @param context contains results to log, and the vm context that contains the global $tw. * @returns */ export function executeScriptInTWContext( scriptContent: string, observer: Subscriber, wikiInstance: ITiddlyWiki, variableContextList: IVariableContextList, index: number, ): void { // execute code in the $tw context const vmContext = getTWVmContext(observer, wikiInstance, variableContextList); let scriptContentWithVariable = ''; try { // prevent user add custom "return" statement that will break the script. const scriptContentWithoutReturn = scriptContent.split('\n').filter((line) => !line.trim().startsWith('return')).join('\n'); if (scriptContentWithoutReturn.length < scriptContent.length) { const message = 'Error: return statement is not allowed in the script. Auto removed.'; observer.next({ type: 'stderr', message }); } const previousVariableContext = variableContextList[index - 1]; scriptContentWithVariable = addVariableIOToContentAPIVersion(scriptContentWithoutReturn, previousVariableContext); } catch (error) { const message = `TW Script init error: ${(error as Error).name}: ${(error as Error).message} ${(error as Error).stack ?? ''}`; observer.next({ type: 'stderr', message }); } try { vm.runInContext(scriptContentWithVariable, vmContext, { timeout: 1000 * 60 * 5 }); } catch (error) { const message = `${(error as Error).name}: ${(error as Error).message} ${(error as Error).stack ?? ''} SourceCode: ${scriptContentWithVariable}`; observer.next({ type: 'stderr', message }); } } export const TW_SCRIPT_SEPARATOR = '/** tw */'; /** * Script that can access nodejs API and that can access $tw API can only live in different execution contexts. We treat some log with TW_SCRIPT_SEPARATOR from zx execution as a script that can be executed in $tw context. * @param scriptContent string that user want to eval * @returns */ export function extractTWContextScripts(scriptContent: string): Array<{ content: string; context: 'zx' | 'tw-server' }> { return ( scriptContent .split(TW_SCRIPT_SEPARATOR) /** 0 | 1 | 2 | 3, means only index is odd, the script is surrounded by the separator, which means it executes in tw-server */ .map((script, index) => (index % 2 === 1 ? { context: 'tw-server' as const, content: script } : { context: 'zx' as const, content: script })) .filter(({ content }) => content.trim().length > 0) ); } export interface ITWVMContext { /** * VM module context */ context: Context; /** * Result from proxy console.log */ executionResults: string[]; } /** * Get context that has global variables like `console` and `$tw`, and a result output buffer that contains result from the `console`. * @returns */ export function getTWVmContext( observer: Subscriber, wikiInstance: ITiddlyWiki, variableContextList: IVariableContextList, ): vm.Context { const proxyConsole = new Proxy( {}, { get: (_target, _propertyName, _receiver) => { return (...messageArguments: unknown[]): void => { const message = messageArguments.map(String).join('\n'); observer.next({ type: 'stdout', message }); }; }, }, ); const context = vm.createContext({ pushVariablesToTWContext(variables: IVariableContext) { if (typeof variables === 'object' && variables !== undefined && variables !== null) { variableContextList.push(variables); } }, console: proxyConsole, $tw: wikiInstance, _, }); return context; }