diff --git a/lib/core/Sandbox.js b/lib/core/Sandbox.js new file mode 100644 index 0000000..b07ef6e --- /dev/null +++ b/lib/core/Sandbox.js @@ -0,0 +1,150 @@ +/** + * 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); + }; +} diff --git a/lib/core/SandboxMessenger.js b/lib/core/SandboxMessenger.js new file mode 100644 index 0000000..0464a7a --- /dev/null +++ b/lib/core/SandboxMessenger.js @@ -0,0 +1,71 @@ +export default class SandboxMessenger { + constructor() { + this.messageQueue = []; + this.responseQueue = []; + this.isProcessing = false; + } + + async sendMessage(message) { + return new Promise((resolve, reject) => { + this.messageQueue.push({ message, resolve, reject }); + this.processMessages(); + }); + } + + async receiveMessage() { + return new Promise((resolve) => { + this.responseQueue.push(resolve); + this.processMessages(); + }); + } + + async processMessages() { + if (this.isProcessing) { + return; + } + + this.isProcessing = true; + + while (this.messageQueue.length > 0 && this.responseQueue.length > 0) { + const { message, resolve, reject } = this.messageQueue.shift(); + const responseCallback = this.responseQueue.shift(); + + try { + // Send the message to the Main Thread and wait for the response + const response = await this.sendMessageToMainThread(message); + + // Resolve the promise with the response + resolve(response); + + // Call the response callback with the response + responseCallback(response); + } catch (error) { + // Reject the promise with the error + reject(error); + } + } + + this.isProcessing = false; + } + + async sendMessageToMainThread(message) { + return new Promise((resolve, reject) => { + // Send the message to the Main Thread + postMessage(message); + + // Listen for the response from the Main Thread + const messageHandler = (event) => { + const response = event.data; + + // Remove the event listener + removeEventListener('message', messageHandler); + + // Resolve the promise with the response + resolve(response); + }; + + // Add the event listener for the response + addEventListener('message', messageHandler, { once: true }); + }); + } +}