|
|
@@ -0,0 +1,486 @@
|
|
|
+#include "smartbotic/runner/js_engine.hpp"
|
|
|
+
|
|
|
+#include <chrono>
|
|
|
+
|
|
|
+#include <spdlog/spdlog.h>
|
|
|
+
|
|
|
+// QuickJS header - will be available after FetchContent
|
|
|
+extern "C" {
|
|
|
+#include <quickjs.h>
|
|
|
+}
|
|
|
+
|
|
|
+namespace smartbotic::runner {
|
|
|
+
|
|
|
+class JsEngine::Impl {
|
|
|
+public:
|
|
|
+ explicit Impl(const JsEngineConfig& config)
|
|
|
+ : config_(config) {
|
|
|
+ // Create QuickJS runtime with memory limit
|
|
|
+ rt_ = JS_NewRuntime();
|
|
|
+ if (!rt_) {
|
|
|
+ spdlog::error("Failed to create QuickJS runtime");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ JS_SetMemoryLimit(rt_, config_.max_memory);
|
|
|
+ JS_SetMaxStackSize(rt_, config_.max_stack_size);
|
|
|
+
|
|
|
+ // Create context
|
|
|
+ ctx_ = JS_NewContext(rt_);
|
|
|
+ if (!ctx_) {
|
|
|
+ spdlog::error("Failed to create QuickJS context");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Disable eval if configured
|
|
|
+ if (!config_.allow_eval) {
|
|
|
+ // Note: QuickJS doesn't have a direct way to disable eval,
|
|
|
+ // but we can override it in JavaScript
|
|
|
+ }
|
|
|
+
|
|
|
+ InitializeBuiltins();
|
|
|
+ }
|
|
|
+
|
|
|
+ ~Impl() {
|
|
|
+ if (ctx_) {
|
|
|
+ JS_FreeContext(ctx_);
|
|
|
+ }
|
|
|
+ if (rt_) {
|
|
|
+ JS_FreeRuntime(rt_);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void SetContext(const JsContext& ctx) {
|
|
|
+ context_ = ctx;
|
|
|
+ UpdateContextInJs();
|
|
|
+ }
|
|
|
+
|
|
|
+ void SetDbCallbacks(DbQueryCallback query_cb,
|
|
|
+ DbGetCallback get_cb,
|
|
|
+ DbCountCallback count_cb) {
|
|
|
+ query_callback_ = std::move(query_cb);
|
|
|
+ get_callback_ = std::move(get_cb);
|
|
|
+ count_callback_ = std::move(count_cb);
|
|
|
+ }
|
|
|
+
|
|
|
+ auto Execute(const std::string& script) -> JsResult {
|
|
|
+ if (!ctx_) {
|
|
|
+ return {false, "", "JavaScript engine not initialized", 0};
|
|
|
+ }
|
|
|
+
|
|
|
+ auto start = std::chrono::steady_clock::now();
|
|
|
+
|
|
|
+ // Set up interrupt handler for timeout
|
|
|
+ interrupt_flag_ = false;
|
|
|
+ JS_SetInterruptHandler(rt_, InterruptHandler, this);
|
|
|
+
|
|
|
+ // Start timeout thread
|
|
|
+ timeout_thread_active_ = true;
|
|
|
+ std::thread timeout_thread([this]() {
|
|
|
+ std::this_thread::sleep_for(std::chrono::milliseconds(config_.max_execution_time_ms));
|
|
|
+ if (timeout_thread_active_) {
|
|
|
+ interrupt_flag_ = true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ timeout_thread.detach();
|
|
|
+
|
|
|
+ // Execute the script
|
|
|
+ JSValue result = JS_Eval(ctx_, script.c_str(), script.length(),
|
|
|
+ "<script>", JS_EVAL_TYPE_GLOBAL);
|
|
|
+
|
|
|
+ timeout_thread_active_ = false;
|
|
|
+
|
|
|
+ auto end = std::chrono::steady_clock::now();
|
|
|
+ auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
|
|
+
|
|
|
+ JsResult js_result;
|
|
|
+ js_result.execution_time_ms = duration.count();
|
|
|
+
|
|
|
+ if (JS_IsException(result)) {
|
|
|
+ JSValue exception = JS_GetException(ctx_);
|
|
|
+ const char* error_str = JS_ToCString(ctx_, exception);
|
|
|
+ js_result.success = false;
|
|
|
+ js_result.error = error_str ? error_str : "Unknown error";
|
|
|
+ if (error_str) {
|
|
|
+ JS_FreeCString(ctx_, error_str);
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx_, exception);
|
|
|
+ } else {
|
|
|
+ js_result.success = true;
|
|
|
+ const char* result_str = JS_ToCString(ctx_, result);
|
|
|
+ js_result.result = result_str ? result_str : "";
|
|
|
+ if (result_str) {
|
|
|
+ JS_FreeCString(ctx_, result_str);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ JS_FreeValue(ctx_, result);
|
|
|
+ return js_result;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto Evaluate(const std::string& expression) -> JsResult {
|
|
|
+ // Wrap expression to return its value
|
|
|
+ std::string script = "(" + expression + ")";
|
|
|
+ return Execute(script);
|
|
|
+ }
|
|
|
+
|
|
|
+ auto GetDbQueryCount() const -> int {
|
|
|
+ return db_query_count_;
|
|
|
+ }
|
|
|
+
|
|
|
+ void Reset() {
|
|
|
+ db_query_count_ = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+private:
|
|
|
+ static int InterruptHandler(JSRuntime* rt, void* opaque) {
|
|
|
+ auto* impl = static_cast<Impl*>(opaque);
|
|
|
+ return impl->interrupt_flag_ ? 1 : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ void InitializeBuiltins() {
|
|
|
+ if (!ctx_) return;
|
|
|
+
|
|
|
+ // Create global 'utils' object with utility functions
|
|
|
+ const char* utils_code = R"(
|
|
|
+ globalThis.utils = {
|
|
|
+ formatDate: function(date, format) {
|
|
|
+ if (typeof date === 'number') {
|
|
|
+ date = new Date(date * 1000);
|
|
|
+ }
|
|
|
+ // Simple date formatting
|
|
|
+ const pad = (n) => n.toString().padStart(2, '0');
|
|
|
+ return format
|
|
|
+ .replace('YYYY', date.getFullYear())
|
|
|
+ .replace('MM', pad(date.getMonth() + 1))
|
|
|
+ .replace('DD', pad(date.getDate()))
|
|
|
+ .replace('HH', pad(date.getHours()))
|
|
|
+ .replace('mm', pad(date.getMinutes()))
|
|
|
+ .replace('ss', pad(date.getSeconds()));
|
|
|
+ },
|
|
|
+ formatNumber: function(num, options) {
|
|
|
+ options = options || {};
|
|
|
+ const decimals = options.decimals || 0;
|
|
|
+ const sep = options.thousandsSeparator !== false;
|
|
|
+ let str = num.toFixed(decimals);
|
|
|
+ if (sep) {
|
|
|
+ const parts = str.split('.');
|
|
|
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
+ str = parts.join('.');
|
|
|
+ }
|
|
|
+ return str;
|
|
|
+ },
|
|
|
+ truncate: function(str, length) {
|
|
|
+ if (str.length <= length) return str;
|
|
|
+ return str.substring(0, length - 3) + '...';
|
|
|
+ },
|
|
|
+ capitalize: function(str) {
|
|
|
+ return str.charAt(0).toUpperCase() + str.slice(1);
|
|
|
+ },
|
|
|
+ now: function() {
|
|
|
+ return Math.floor(Date.now() / 1000);
|
|
|
+ },
|
|
|
+ uuid: function() {
|
|
|
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
|
+ const r = Math.random() * 16 | 0;
|
|
|
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
|
+ return v.toString(16);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Disable eval for security
|
|
|
+ globalThis.eval = function() {
|
|
|
+ throw new Error('eval is disabled for security');
|
|
|
+ };
|
|
|
+ globalThis.Function = function() {
|
|
|
+ throw new Error('Function constructor is disabled for security');
|
|
|
+ };
|
|
|
+ )";
|
|
|
+
|
|
|
+ JSValue result = JS_Eval(ctx_, utils_code, strlen(utils_code),
|
|
|
+ "<builtin>", JS_EVAL_TYPE_GLOBAL);
|
|
|
+ JS_FreeValue(ctx_, result);
|
|
|
+
|
|
|
+ // Set up native db functions (we'll implement these with callbacks)
|
|
|
+ SetupDbFunctions();
|
|
|
+ }
|
|
|
+
|
|
|
+ void SetupDbFunctions() {
|
|
|
+ if (!ctx_) return;
|
|
|
+
|
|
|
+ // Store this pointer in context for callbacks
|
|
|
+ JS_SetContextOpaque(ctx_, this);
|
|
|
+
|
|
|
+ // Create db object with native functions
|
|
|
+ JSValue global = JS_GetGlobalObject(ctx_);
|
|
|
+ JSValue db_obj = JS_NewObject(ctx_);
|
|
|
+
|
|
|
+ // db.get function
|
|
|
+ JS_SetPropertyStr(ctx_, db_obj, "get",
|
|
|
+ JS_NewCFunction(ctx_, DbGetNative, "get", 2));
|
|
|
+
|
|
|
+ // db.query function
|
|
|
+ JS_SetPropertyStr(ctx_, db_obj, "query",
|
|
|
+ JS_NewCFunction(ctx_, DbQueryNative, "query", 3));
|
|
|
+
|
|
|
+ // db.count function
|
|
|
+ JS_SetPropertyStr(ctx_, db_obj, "count",
|
|
|
+ JS_NewCFunction(ctx_, DbCountNative, "count", 2));
|
|
|
+
|
|
|
+ JS_SetPropertyStr(ctx_, global, "db", db_obj);
|
|
|
+ JS_FreeValue(ctx_, global);
|
|
|
+ }
|
|
|
+
|
|
|
+ static JSValue DbGetNative(JSContext* ctx, JSValueConst this_val,
|
|
|
+ int argc, JSValueConst* argv) {
|
|
|
+ auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
|
|
|
+ if (!impl || !impl->get_callback_) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.get not available");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (argc < 2) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.get requires collection and id");
|
|
|
+ }
|
|
|
+
|
|
|
+ impl->db_query_count_++;
|
|
|
+ if (impl->db_query_count_ > impl->config_.max_db_queries) {
|
|
|
+ return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
|
|
|
+ }
|
|
|
+
|
|
|
+ const char* collection = JS_ToCString(ctx, argv[0]);
|
|
|
+ const char* id = JS_ToCString(ctx, argv[1]);
|
|
|
+
|
|
|
+ if (!collection || !id) {
|
|
|
+ if (collection) JS_FreeCString(ctx, collection);
|
|
|
+ if (id) JS_FreeCString(ctx, id);
|
|
|
+ return JS_ThrowTypeError(ctx, "Invalid arguments");
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string result = impl->get_callback_(collection, id);
|
|
|
+
|
|
|
+ JS_FreeCString(ctx, collection);
|
|
|
+ JS_FreeCString(ctx, id);
|
|
|
+
|
|
|
+ if (result.empty()) {
|
|
|
+ return JS_NULL;
|
|
|
+ }
|
|
|
+
|
|
|
+ return JS_ParseJSON(ctx, result.c_str(), result.length(), "<db.get>");
|
|
|
+ }
|
|
|
+
|
|
|
+ static JSValue DbQueryNative(JSContext* ctx, JSValueConst this_val,
|
|
|
+ int argc, JSValueConst* argv) {
|
|
|
+ auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
|
|
|
+ if (!impl || !impl->query_callback_) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.query not available");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (argc < 1) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.query requires collection");
|
|
|
+ }
|
|
|
+
|
|
|
+ impl->db_query_count_++;
|
|
|
+ if (impl->db_query_count_ > impl->config_.max_db_queries) {
|
|
|
+ return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
|
|
|
+ }
|
|
|
+
|
|
|
+ const char* collection = JS_ToCString(ctx, argv[0]);
|
|
|
+ if (!collection) {
|
|
|
+ return JS_ThrowTypeError(ctx, "Invalid collection");
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string filter_json = "{}";
|
|
|
+ int limit = 100;
|
|
|
+ int offset = 0;
|
|
|
+
|
|
|
+ if (argc >= 2 && !JS_IsUndefined(argv[1])) {
|
|
|
+ JSValue json_str = JS_JSONStringify(ctx, argv[1], JS_UNDEFINED, JS_UNDEFINED);
|
|
|
+ const char* str = JS_ToCString(ctx, json_str);
|
|
|
+ if (str) {
|
|
|
+ filter_json = str;
|
|
|
+ JS_FreeCString(ctx, str);
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, json_str);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (argc >= 3 && !JS_IsUndefined(argv[2])) {
|
|
|
+ // Options object with limit and offset
|
|
|
+ JSValue limit_val = JS_GetPropertyStr(ctx, argv[2], "limit");
|
|
|
+ JSValue offset_val = JS_GetPropertyStr(ctx, argv[2], "offset");
|
|
|
+
|
|
|
+ if (!JS_IsUndefined(limit_val)) {
|
|
|
+ int32_t l;
|
|
|
+ if (JS_ToInt32(ctx, &l, limit_val) == 0) {
|
|
|
+ limit = std::min(l, 1000); // Cap at 1000
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!JS_IsUndefined(offset_val)) {
|
|
|
+ int32_t o;
|
|
|
+ if (JS_ToInt32(ctx, &o, offset_val) == 0) {
|
|
|
+ offset = o;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ JS_FreeValue(ctx, limit_val);
|
|
|
+ JS_FreeValue(ctx, offset_val);
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string result = impl->query_callback_(collection, filter_json, limit, offset);
|
|
|
+ JS_FreeCString(ctx, collection);
|
|
|
+
|
|
|
+ if (result.empty()) {
|
|
|
+ return JS_NewArray(ctx);
|
|
|
+ }
|
|
|
+
|
|
|
+ return JS_ParseJSON(ctx, result.c_str(), result.length(), "<db.query>");
|
|
|
+ }
|
|
|
+
|
|
|
+ static JSValue DbCountNative(JSContext* ctx, JSValueConst this_val,
|
|
|
+ int argc, JSValueConst* argv) {
|
|
|
+ auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
|
|
|
+ if (!impl || !impl->count_callback_) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.count not available");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (argc < 1) {
|
|
|
+ return JS_ThrowTypeError(ctx, "db.count requires collection");
|
|
|
+ }
|
|
|
+
|
|
|
+ impl->db_query_count_++;
|
|
|
+ if (impl->db_query_count_ > impl->config_.max_db_queries) {
|
|
|
+ return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
|
|
|
+ }
|
|
|
+
|
|
|
+ const char* collection = JS_ToCString(ctx, argv[0]);
|
|
|
+ if (!collection) {
|
|
|
+ return JS_ThrowTypeError(ctx, "Invalid collection");
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string filter_json = "{}";
|
|
|
+ if (argc >= 2 && !JS_IsUndefined(argv[1])) {
|
|
|
+ JSValue json_str = JS_JSONStringify(ctx, argv[1], JS_UNDEFINED, JS_UNDEFINED);
|
|
|
+ const char* str = JS_ToCString(ctx, json_str);
|
|
|
+ if (str) {
|
|
|
+ filter_json = str;
|
|
|
+ JS_FreeCString(ctx, str);
|
|
|
+ }
|
|
|
+ JS_FreeValue(ctx, json_str);
|
|
|
+ }
|
|
|
+
|
|
|
+ int64_t count = impl->count_callback_(collection, filter_json);
|
|
|
+ JS_FreeCString(ctx, collection);
|
|
|
+
|
|
|
+ return JS_NewInt64(ctx, count);
|
|
|
+ }
|
|
|
+
|
|
|
+ void UpdateContextInJs() {
|
|
|
+ if (!ctx_) return;
|
|
|
+
|
|
|
+ // Build context object as JSON and parse it in JS
|
|
|
+ std::string ctx_json = R"({
|
|
|
+ "user": {
|
|
|
+ "id": ")" + EscapeJson(context_.user_id) + R"(",
|
|
|
+ "name": ")" + EscapeJson(context_.user_name) + R"(",
|
|
|
+ "email": ")" + EscapeJson(context_.user_email) + R"(",
|
|
|
+ "role": ")" + EscapeJson(context_.user_role) + R"("
|
|
|
+ },
|
|
|
+ "workspace": {
|
|
|
+ "id": ")" + EscapeJson(context_.workspace_id) + R"(",
|
|
|
+ "name": ")" + EscapeJson(context_.workspace_name) + R"("
|
|
|
+ },
|
|
|
+ "page": {
|
|
|
+ "path": ")" + EscapeJson(context_.page_path) + R"(",
|
|
|
+ "title": ")" + EscapeJson(context_.page_title) + R"(",
|
|
|
+ "collection": ")" + EscapeJson(context_.collection) + R"(",
|
|
|
+ "view_id": ")" + EscapeJson(context_.view_id) + R"("
|
|
|
+ },
|
|
|
+ "session": {
|
|
|
+ "id": ")" + EscapeJson(context_.session_id) + R"(",
|
|
|
+ "title": ")" + EscapeJson(context_.session_title) + R"(",
|
|
|
+ "model_id": ")" + EscapeJson(context_.model_id) + R"("
|
|
|
+ }
|
|
|
+ })";
|
|
|
+
|
|
|
+ std::string code = "globalThis.ctx = " + ctx_json + ";";
|
|
|
+ // Also expose context objects directly in global scope for template convenience
|
|
|
+ code += "globalThis.user = ctx.user;";
|
|
|
+ code += "globalThis.workspace = ctx.workspace;";
|
|
|
+ code += "globalThis.page = ctx.page;";
|
|
|
+ code += "globalThis.session = ctx.session;";
|
|
|
+ for (const auto& [key, value] : context_.custom) {
|
|
|
+ code += "globalThis.ctx['" + EscapeJson(key) + "'] = '" + EscapeJson(value) + "';";
|
|
|
+ // Also define custom variables directly in global scope for templates
|
|
|
+ code += "globalThis['" + EscapeJson(key) + "'] = '" + EscapeJson(value) + "';";
|
|
|
+ }
|
|
|
+
|
|
|
+ JSValue result = JS_Eval(ctx_, code.c_str(), code.length(),
|
|
|
+ "<context>", JS_EVAL_TYPE_GLOBAL);
|
|
|
+ JS_FreeValue(ctx_, result);
|
|
|
+ }
|
|
|
+
|
|
|
+ static std::string EscapeJson(const std::string& str) {
|
|
|
+ std::string result;
|
|
|
+ result.reserve(str.size());
|
|
|
+ for (char c : str) {
|
|
|
+ switch (c) {
|
|
|
+ case '"': result += "\\\""; break;
|
|
|
+ case '\\': result += "\\\\"; break;
|
|
|
+ case '\n': result += "\\n"; break;
|
|
|
+ case '\r': result += "\\r"; break;
|
|
|
+ case '\t': result += "\\t"; break;
|
|
|
+ default: result += c; break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ JsEngineConfig config_;
|
|
|
+ JSRuntime* rt_ = nullptr;
|
|
|
+ JSContext* ctx_ = nullptr;
|
|
|
+
|
|
|
+ JsContext context_;
|
|
|
+ DbQueryCallback query_callback_;
|
|
|
+ DbGetCallback get_callback_;
|
|
|
+ DbCountCallback count_callback_;
|
|
|
+
|
|
|
+ int db_query_count_ = 0;
|
|
|
+ std::atomic<bool> interrupt_flag_{false};
|
|
|
+ std::atomic<bool> timeout_thread_active_{false};
|
|
|
+};
|
|
|
+
|
|
|
+JsEngine::JsEngine(const JsEngineConfig& config)
|
|
|
+ : impl_(std::make_unique<Impl>(config)) {}
|
|
|
+
|
|
|
+JsEngine::~JsEngine() = default;
|
|
|
+
|
|
|
+JsEngine::JsEngine(JsEngine&&) noexcept = default;
|
|
|
+JsEngine& JsEngine::operator=(JsEngine&&) noexcept = default;
|
|
|
+
|
|
|
+void JsEngine::SetContext(const JsContext& ctx) {
|
|
|
+ impl_->SetContext(ctx);
|
|
|
+}
|
|
|
+
|
|
|
+void JsEngine::SetDbCallbacks(DbQueryCallback query_cb,
|
|
|
+ DbGetCallback get_cb,
|
|
|
+ DbCountCallback count_cb) {
|
|
|
+ impl_->SetDbCallbacks(std::move(query_cb), std::move(get_cb), std::move(count_cb));
|
|
|
+}
|
|
|
+
|
|
|
+auto JsEngine::Execute(const std::string& script) -> JsResult {
|
|
|
+ return impl_->Execute(script);
|
|
|
+}
|
|
|
+
|
|
|
+auto JsEngine::Evaluate(const std::string& expression) -> JsResult {
|
|
|
+ return impl_->Evaluate(expression);
|
|
|
+}
|
|
|
+
|
|
|
+auto JsEngine::GetDbQueryCount() const -> int {
|
|
|
+ return impl_->GetDbQueryCount();
|
|
|
+}
|
|
|
+
|
|
|
+void JsEngine::Reset() {
|
|
|
+ impl_->Reset();
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace smartbotic::runner
|