/** * Represents a sandbox for executing code in a controlled environment. */ export default class Sandbox { #callbacks = new Map(); #worker = null; constructor() {} /** * Executes the provided code and invokes the callback function with the result. * @param {string} code - The code to be executed. * @param {function} cb - The callback function to be invoked with the result. * @returns {string} - The unique identifier for the execution. * @throws {Error} - If the callback is not a function or if the code is not a string. */ execute(code, cb) { if (typeof cb !== 'function') throw new Error('Callback must be a function'); if (typeof code !== 'string') throw new Error('Code must be a string'); if (code == '') throw new Error('Code cannot be empty'); const uniqueId = Math.random().toString(36).substring(2, 9); if (!this.#worker) this.#startup(code, cb); this.#callbacks.set(uniqueId, cb); this.#worker.postMessage({ code, id: uniqueId }); return uniqueId; } /** * Handles incoming messages and invokes the corresponding callback. * @param {Event} event - The message event. */ #onMessage(event) { for (let [id, cb] of this.#callbacks) { if (event.data.id === id) { cb(event.data); this.#callbacks.delete(id); break; } } } /** * Stops the execution of a callback with the given ID. * @param {string} id - The ID of the callback to stop. */ stop(id) { this.#callbacks.delete(id); if (this.#callbacks.size === 0) { this.#terminate(); } } /** * Starts the sandbox by initializing the worker and setting up the event listener for messages. * * @param {string} code - The code to be executed in the sandbox. * @param {function} cb - The callback function to be called when a message is received from the worker. */ #startup(code, cb) { if (this.#worker) return; // Already started const workerCode = workerFunction .toString() .replace(/^[^{]*{\s*/, '') .replace(/\s*}[^}]*$/, ''); const workerBlob = new Blob(workerCode, { type: 'text/javascript', }); const workerUrl = URL.createObjectURL(workerBlob); this.#worker = new Worker(workerUrl); this.#worker.addEventListener('message', this.#onMessage.bind(this)); } /** * Terminates the worker and clears the callbacks. */ #terminate() { this.#worker.terminate(); this.#callbacks.clear(); } } /** * Gets inserted into the web worker for executing code. * Executes the code received from the event data in a sandboxed environment. * @function workerFunction */ function workerFunction() { self.onmessage = (event) => { // Get the code from the event data const { id, code } = event.data; // Define the sandbox API const sandbox = { console: { log: (...args) => { self.postMessage({ type: 'log', data: args, id }); }, error: (...args) => { self.postMessage({ type: 'error', data: args, id }); }, warn: (...args) => { self.postMessage({ type: 'warn', data: args, id }); }, }, setTimeout: (callback, delay) => { const timeoutId = setTimeout(() => { callback(); sandbox.timeouts.delete(timeoutId); }, delay); sandbox.timeouts.set(timeoutId, true); return timeoutId; }, clearTimeout: (timeoutId) => { clearTimeout(timeoutId); sandbox.timeouts.delete(timeoutId); }, setInterval: (callback, interval) => { const intervalId = setInterval(callback, interval); sandbox.intervals.set(intervalId, Date.now()); return intervalId; }, clearInterval: (intervalId) => { clearInterval(intervalId); sandbox.intervals.delete(intervalId); }, timeouts: new Map(), intervals: new Map(), // overwrite self.postMessage to always send the public id with the message postMessage: (data) => { self.postMessage({ ...data, id }); }, }; Object.freeze(sandbox); const sbCode = [ 'try {', ' ' + code, '} catch (error) {', ' self.postMessage({ type: "fatal", data: [error] });', '}', ].join('\n'); // Wrap the code in a function and execute it in the context of the sandbox const func = new Function('sandbox', `with (sandbox) { ${sbCode} }`); func(sandbox); }; }