feat(sandbox) implement sandbox functionality for lazy loaded plugins

This commit is contained in:
Benjamin Wegener
2024-01-11 23:23:01 +00:00
parent 1421a6c3a8
commit 01d49e4c6a
2 changed files with 221 additions and 0 deletions

150
lib/core/Sandbox.js Normal file
View File

@@ -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);
};
}

View File

@@ -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 });
});
}
}