chore: full stack stability and migration fixes, plus react UI progress
This commit is contained in:
@@ -15,9 +15,10 @@ async fn main() {
|
||||
let payload = Some(json!({"test": "data"}));
|
||||
let headers = HashMap::new();
|
||||
|
||||
let env_vars = HashMap::new();
|
||||
println!("Starting execution...");
|
||||
match runtime.execute(code, payload, headers).await {
|
||||
Ok((stdout, stderr, status, res_headers)) => {
|
||||
match runtime.execute(code, payload, headers, env_vars).await {
|
||||
Ok((stdout, stderr, status, res_headers, _logs)) => {
|
||||
println!("Success!");
|
||||
println!("Status: {}", status);
|
||||
println!("Stdout: {}", stdout);
|
||||
|
||||
@@ -1,54 +1,222 @@
|
||||
use anyhow::Result;
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8};
|
||||
use serde_json::Value;
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8, ModuleLoader, ModuleSource, ModuleSourceCode, ModuleType, ModuleLoadResponse, RequestedModuleType};
|
||||
use serde_json::json;
|
||||
use deno_ast::{ParseParams, MediaType};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DenoRuntime {
|
||||
// We create a new runtime for each execution to ensure isolation
|
||||
// In a production environment, we might want to pool runtimes or use isolates more efficiently
|
||||
deno_core::extension!(
|
||||
madbase_runtime,
|
||||
ops = [op_fetch],
|
||||
);
|
||||
|
||||
#[deno_core::op2(async)]
|
||||
#[serde]
|
||||
async fn op_fetch(
|
||||
#[string] url: String,
|
||||
#[string] method: String,
|
||||
#[serde] headers: HashMap<String, String>,
|
||||
#[serde] body: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, deno_core::error::AnyError> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut builder = match method.to_uppercase().as_str() {
|
||||
"POST" => client.post(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
_ => client.get(&url),
|
||||
};
|
||||
|
||||
for (k, v) in headers {
|
||||
builder = builder.header(k, v);
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
builder = builder.json(&b);
|
||||
}
|
||||
|
||||
let res = builder.send().await?;
|
||||
let status = res.status().as_u16();
|
||||
let mut res_headers = HashMap::new();
|
||||
for (k, v) in res.headers() {
|
||||
res_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
|
||||
}
|
||||
let text = res.text().await?;
|
||||
|
||||
Ok(json!({
|
||||
"status": status,
|
||||
"headers": res_headers,
|
||||
"body": text
|
||||
}))
|
||||
}
|
||||
|
||||
struct SandboxedModuleLoader {
|
||||
allowed_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ModuleLoader for SandboxedModuleLoader {
|
||||
fn resolve(&self, specifier: &str, referrer: &str, _kind: deno_core::ResolutionKind) -> Result<deno_core::ModuleSpecifier, anyhow::Error> {
|
||||
let resolved = deno_core::resolve_import(specifier, referrer)?;
|
||||
if resolved.scheme() == "file" {
|
||||
let path = resolved.to_file_path().map_err(|_| anyhow::anyhow!("Invalid file path"))?;
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
if !canonical.starts_with(&self.allowed_dir) {
|
||||
return Err(anyhow::anyhow!("Import blocked: {} is outside allowed directory", specifier));
|
||||
}
|
||||
}
|
||||
if resolved.scheme() != "file" && resolved.scheme() != "https" && resolved.scheme() != "http" {
|
||||
return Err(anyhow::anyhow!("Blocked import scheme: {}", resolved.scheme()));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn load(&self, specifier: &deno_core::ModuleSpecifier, _maybe_referrer: Option<&deno_core::ModuleSpecifier>, _is_dynamic: bool, _requested_module_type: RequestedModuleType) -> ModuleLoadResponse {
|
||||
let specifier = specifier.clone();
|
||||
if specifier.scheme() == "file" {
|
||||
let path = specifier.to_file_path().unwrap();
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
let code = tokio::fs::read_to_string(&path).await?;
|
||||
let is_ts = path.extension().is_some_and(|ext| ext == "ts");
|
||||
let transformed = if is_ts {
|
||||
DenoRuntime::transpile(&code, &path)?
|
||||
} else {
|
||||
code
|
||||
};
|
||||
Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(transformed.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))
|
||||
}))
|
||||
} else {
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
Err(anyhow::anyhow!("Remote imports not fully implemented in loader yet"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn near_heap_limit_callback(
|
||||
data: *mut std::ffi::c_void,
|
||||
current_limit: usize,
|
||||
_initial_limit: usize,
|
||||
) -> usize {
|
||||
if !data.is_null() {
|
||||
// SAFETY: data is a *mut v8::Isolate passed from the same thread
|
||||
let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
|
||||
isolate.terminate_execution();
|
||||
}
|
||||
// Give a small amount of extra room so V8 can wind down gracefully
|
||||
// instead of calling FatalProcessOutOfMemory
|
||||
current_limit + 4 * 1024 * 1024
|
||||
}
|
||||
|
||||
pub struct DenoRuntime {}
|
||||
|
||||
impl Default for DenoRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DenoRuntime {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
|
||||
pub fn transpile(code: &str, path: &Path) -> Result<String> {
|
||||
let media_type = MediaType::from_path(path);
|
||||
let specifier = deno_core::url::Url::parse(&format!("file://{}", path.display()))
|
||||
.unwrap_or_else(|_| deno_core::url::Url::parse("file:///index.ts").unwrap());
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
specifier,
|
||||
text: Arc::from(code),
|
||||
media_type,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})?;
|
||||
let transpiled = parsed.transpile(
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
)?;
|
||||
Ok(transpiled.into_source().text)
|
||||
}
|
||||
|
||||
pub async fn execute(&self, code: String, payload: Option<serde_json::Value>, headers: HashMap<String, String>, env_vars: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>, Vec<serde_json::Value>)> {
|
||||
let timeout_secs = std::env::var("FUNCTION_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30u64);
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let result = Self::execute_inner(code, payload, headers).await;
|
||||
let result = Self::execute_inner(code, payload, headers, env_vars).await;
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
});
|
||||
|
||||
rx.await.map_err(|_| anyhow::anyhow!("Deno execution thread panicked"))?
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
rx,
|
||||
).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(_)) => Err(anyhow::anyhow!("Deno execution thread panicked")),
|
||||
Err(_) => Err(anyhow::anyhow!("Function execution timed out after {}s", timeout_secs)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_inner(code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
|
||||
// Initialize JS Runtime
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||
pub(crate) async fn execute_inner(mut code: String, payload: Option<serde_json::Value>, headers: HashMap<String, String>, env_vars: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>, Vec<serde_json::Value>)> {
|
||||
let allowed_dir = PathBuf::from("/tmp/madbase_functions");
|
||||
if !allowed_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&allowed_dir);
|
||||
}
|
||||
|
||||
// Transpile entry code if it looks like TS (or we can just always try)
|
||||
if code.contains(':') || code.contains("type ") || code.contains("interface ") {
|
||||
if let Ok(transformed) = Self::transpile(&code, Path::new("index.ts")) {
|
||||
code = transformed;
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
module_loader: Some(Rc::new(SandboxedModuleLoader { allowed_dir })),
|
||||
create_params: Some(v8::CreateParams::default().heap_limits(0, 128 * 1024 * 1024)),
|
||||
extensions: vec![madbase_runtime::init_ops()],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let isolate = runtime.v8_isolate();
|
||||
let isolate_ptr: *mut v8::Isolate = &mut **isolate;
|
||||
// SAFETY: the callback runs on the same thread as the isolate
|
||||
isolate.add_near_heap_limit_callback(near_heap_limit_callback, isolate_ptr as *mut std::ffi::c_void);
|
||||
|
||||
let env_json = serde_json::to_string(&env_vars)?;
|
||||
runtime.execute_script("<env>", format!("globalThis._env = JSON.parse('{}');", env_json))?;
|
||||
|
||||
// 1. Inject Preamble (Polyfills for Deno.serve, Request, Response, Headers)
|
||||
let preamble = r#"
|
||||
globalThis.__logs__ = [];
|
||||
globalThis.console = {
|
||||
log: (...args) => {
|
||||
Deno.core.print(args.map(a => String(a)).join(" ") + "\n");
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "info", msg, ts: Date.now() });
|
||||
Deno.core.print(msg + "\n");
|
||||
},
|
||||
error: (...args) => {
|
||||
Deno.core.print("[ERROR] " + args.map(a => String(a)).join(" ") + "\n", true);
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "error", msg, ts: Date.now() });
|
||||
Deno.core.print("[ERROR] " + msg + "\n", true);
|
||||
},
|
||||
warn: (...args) => {
|
||||
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(" ");
|
||||
globalThis.__logs__.push({ level: "warn", msg, ts: Date.now() });
|
||||
Deno.core.print("[WARN] " + msg + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,22 +241,7 @@ impl DenoRuntime {
|
||||
}
|
||||
globalThis.Headers = Headers;
|
||||
|
||||
globalThis.Deno = {
|
||||
serve: (handler) => {
|
||||
globalThis._handler = handler;
|
||||
},
|
||||
core: Deno.core,
|
||||
env: {
|
||||
get: (key) => {
|
||||
return globalThis._env ? globalThis._env[key] : null;
|
||||
},
|
||||
toObject: () => {
|
||||
return globalThis._env || {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class Response {
|
||||
globalThis.Response = class Response {
|
||||
constructor(body, init) {
|
||||
this.body = body;
|
||||
this.status = init?.status || 200;
|
||||
@@ -96,10 +249,9 @@ impl DenoRuntime {
|
||||
}
|
||||
async text() { return String(this.body); }
|
||||
async json() { return JSON.parse(this.body); }
|
||||
}
|
||||
globalThis.Response = Response;
|
||||
};
|
||||
|
||||
class Request {
|
||||
globalThis.Request = class Request {
|
||||
constructor(url, init) {
|
||||
this.url = url;
|
||||
this.method = init?.method || "GET";
|
||||
@@ -108,28 +260,43 @@ impl DenoRuntime {
|
||||
}
|
||||
async json() { return typeof this._body === 'string' ? JSON.parse(this._body) : this._body; }
|
||||
async text() { return typeof this._body === 'string' ? this._body : JSON.stringify(this._body); }
|
||||
}
|
||||
globalThis.Request = Request;
|
||||
};
|
||||
|
||||
globalThis.fetch = async (url, init) => {
|
||||
const method = init?.method || "GET";
|
||||
const headers = {};
|
||||
if (init?.headers) {
|
||||
const h = new Headers(init.headers);
|
||||
h.forEach((v, k) => headers[k] = v);
|
||||
}
|
||||
let body = init?.body;
|
||||
if (body && typeof body !== 'string') body = JSON.stringify(body);
|
||||
|
||||
const res = await Deno.core.ops.op_fetch(url, method, headers, body);
|
||||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||||
};
|
||||
|
||||
globalThis.Deno = {
|
||||
serve: (handler) => { globalThis._handler = handler; },
|
||||
core: Deno.core,
|
||||
env: {
|
||||
get: (key) => globalThis._env ? globalThis._env[key] : null,
|
||||
toObject: () => globalThis._env || {}
|
||||
}
|
||||
};
|
||||
"#;
|
||||
|
||||
runtime.execute_script("<preamble>", preamble.to_string())?;
|
||||
|
||||
// 2. Execute User Code
|
||||
runtime.execute_script("<user_script>", code.to_string())?;
|
||||
|
||||
// 3. Invoke Handler
|
||||
// Double-serialize to prevent JS injection: the outer JSON string is parsed
|
||||
// by JSON.parse() in JS, producing the original value safely.
|
||||
let payload_json = serde_json::to_string(&payload.unwrap_or(serde_json::json!({})))?;
|
||||
let payload_json = serde_json::to_string(&payload.unwrap_or(json!({})))?;
|
||||
let headers_json = serde_json::to_string(&headers)?;
|
||||
let safe_payload = serde_json::to_string(&payload_json)?;
|
||||
let safe_headers = serde_json::to_string(&headers_json)?;
|
||||
|
||||
let invoke_script = format!(r#"
|
||||
(async () => {{
|
||||
if (!globalThis._handler) {{
|
||||
return {{ error: "No handler registered via Deno.serve" }};
|
||||
}}
|
||||
if (!globalThis._handler) return {{ error: "No handler registered via Deno.serve" }};
|
||||
try {{
|
||||
const headers = JSON.parse({1});
|
||||
const body = JSON.parse({0});
|
||||
@@ -140,19 +307,13 @@ impl DenoRuntime {
|
||||
}});
|
||||
const res = await globalThis._handler(req);
|
||||
const text = await res.text();
|
||||
|
||||
const resHeaders = {{}};
|
||||
if (res.headers && typeof res.headers.forEach === 'function') {{
|
||||
res.headers.forEach((v, k) => resHeaders[k] = v);
|
||||
}}
|
||||
|
||||
return {{
|
||||
result: text,
|
||||
headers: resHeaders,
|
||||
status: res.status
|
||||
}};
|
||||
return {{ result: text, headers: resHeaders, status: res.status, logs: globalThis.__logs__ }};
|
||||
}} catch (e) {{
|
||||
return {{ error: String(e) }};
|
||||
return {{ error: String(e), logs: globalThis.__logs__ }};
|
||||
}}
|
||||
}})()
|
||||
"#, safe_payload, safe_headers);
|
||||
@@ -160,100 +321,272 @@ impl DenoRuntime {
|
||||
let result_val = runtime.execute_script("<invocation>", invoke_script)?;
|
||||
#[allow(deprecated)]
|
||||
let result = runtime.resolve_value(result_val).await?;
|
||||
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
let deserialized_value: Value = deno_core::serde_v8::from_v8(scope, local)?;
|
||||
let deserialized_value: serde_json::Value = deno_core::serde_v8::from_v8(scope, local)?;
|
||||
|
||||
let stdout = if let Some(res) = deserialized_value.get("result") {
|
||||
res.as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let stderr = if let Some(err) = deserialized_value.get("error") {
|
||||
err.as_str().unwrap_or("Unknown error").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let status = if let Some(s) = deserialized_value.get("status") {
|
||||
s.as_u64().unwrap_or(200) as u16
|
||||
} else {
|
||||
200
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(h) = deserialized_value.get("headers") {
|
||||
if let Some(obj) = h.as_object() {
|
||||
for (k, v) in obj {
|
||||
if let Some(s) = v.as_str() {
|
||||
headers.insert(k.clone(), s.to_string());
|
||||
}
|
||||
}
|
||||
let stdout = deserialized_value.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let stderr = deserialized_value.get("error").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let status = deserialized_value.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
|
||||
let mut res_headers = HashMap::new();
|
||||
if let Some(h) = deserialized_value.get("headers").and_then(|v| v.as_object()) {
|
||||
for (k, v) in h {
|
||||
if let Some(s) = v.as_str() { res_headers.insert(k.clone(), s.to_string()); }
|
||||
}
|
||||
}
|
||||
let logs = deserialized_value.get("logs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
Ok((stdout, stderr, status, headers))
|
||||
Ok((stdout, stderr, status, res_headers, logs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{json, Value};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Validates that the double-serialization technique produces safe JS string
|
||||
/// literals, even when the payload contains characters that could break out
|
||||
/// of a JS template if interpolated naively.
|
||||
#[test]
|
||||
fn test_double_serialize_escapes_js_injection() {
|
||||
let malicious_payload = json!({
|
||||
"key": "\"); process.exit(1); //"
|
||||
});
|
||||
// --- Sandbox tests ---
|
||||
|
||||
let first = serde_json::to_string(&malicious_payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
// The double-serialized value must be a valid JSON string
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, malicious_payload);
|
||||
fn make_loader(dir: &str) -> SandboxedModuleLoader {
|
||||
SandboxedModuleLoader { allowed_dir: PathBuf::from(dir) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_handles_backtick_injection() {
|
||||
let payload = json!({
|
||||
"attack": "${globalThis.Deno.exit()}"
|
||||
});
|
||||
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
// The value when placed in a JS template literal is still just a string
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
fn test_sandboxed_loader_blocks_etc_passwd() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("/etc/passwd", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block /etc/passwd");
|
||||
assert!(result.unwrap_err().to_string().contains("outside allowed directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_handles_empty() {
|
||||
let payload = json!({});
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
fn test_sandboxed_loader_blocks_parent_traversal() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("../../etc/passwd", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block parent traversal to /etc/passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_serialize_preserves_unicode() {
|
||||
let payload = json!({"emoji": "🔐", "chinese": "安全"});
|
||||
let first = serde_json::to_string(&payload).unwrap();
|
||||
let double = serde_json::to_string(&first).unwrap();
|
||||
fn test_sandboxed_loader_allows_local_import() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("./helper.ts", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
// resolve succeeds even if the file doesn't exist (file lookup happens in load())
|
||||
assert!(result.is_ok(), "Should allow ./helper.ts within allowed dir");
|
||||
}
|
||||
|
||||
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||
assert_eq!(recovered, payload);
|
||||
#[test]
|
||||
fn test_sandboxed_loader_allows_https_import() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("https://deno.land/std/testing/asserts.ts", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_ok(), "Should allow https:// imports");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandboxed_loader_blocks_ftp() {
|
||||
let loader = make_loader("/tmp/madbase_functions");
|
||||
let result = loader.resolve("ftp://evil.com/payload", "file:///tmp/madbase_functions/index.ts", deno_core::ResolutionKind::Import);
|
||||
assert!(result.is_err(), "Should block ftp:// scheme");
|
||||
assert!(result.unwrap_err().to_string().contains("Blocked import scheme"));
|
||||
}
|
||||
|
||||
// --- JS injection safety ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_js_injection_safe_payload() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const body = await req.text();
|
||||
return new Response(JSON.stringify({ received: body, alive: true }));
|
||||
});
|
||||
"#.to_string();
|
||||
let malicious_payload = json!({"key": "'; process.exit(); '"});
|
||||
let (stdout, stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, Some(malicious_payload), HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
// The critical assertion: the runtime didn't crash and returned a response
|
||||
let res: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(res["alive"], true, "Runtime survived malicious payload, stderr={}", stderr);
|
||||
assert!(res["received"].as_str().unwrap().contains("process.exit()"), "Malicious string was preserved as data");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_js_injection_safe_headers() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const val = req.headers.get("x-evil");
|
||||
return new Response(val || "none");
|
||||
});
|
||||
"#.to_string();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-evil".to_string(), "\"});process.exit();//".to_string());
|
||||
let (stdout, stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, headers.clone(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stderr.is_empty(), "Should not crash: stderr={}", stderr);
|
||||
assert_eq!(stdout, headers["x-evil"]);
|
||||
}
|
||||
|
||||
// --- Resource limits ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeout_enforcement() {
|
||||
// Use a short timeout for testing
|
||||
std::env::set_var("FUNCTION_TIMEOUT_SECS", "2");
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
while(true) {}
|
||||
return new Response("unreachable");
|
||||
});
|
||||
"#.to_string();
|
||||
let result = runtime.execute(code, None, HashMap::new(), HashMap::new()).await;
|
||||
std::env::remove_var("FUNCTION_TIMEOUT_SECS");
|
||||
assert!(result.is_err(), "Infinite loop should be terminated by timeout");
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("timed out") || err_msg.contains("panicked"),
|
||||
"Error should mention timeout, got: {}", err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_limit_enforcement() {
|
||||
let runtime = DenoRuntime::new();
|
||||
// Use JS objects/strings that consume V8 managed heap (not external backing stores)
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const arr = [];
|
||||
while (true) {
|
||||
arr.push("x".repeat(10000) + Math.random().toString());
|
||||
}
|
||||
return new Response("should not reach here");
|
||||
});
|
||||
"#.to_string();
|
||||
std::env::set_var("FUNCTION_TIMEOUT_SECS", "10");
|
||||
let result = runtime.execute(code, None, HashMap::new(), HashMap::new()).await;
|
||||
std::env::remove_var("FUNCTION_TIMEOUT_SECS");
|
||||
// V8 OOMs, the thread panics, or the timeout fires — any of these is an error
|
||||
assert!(result.is_err(), "Should fail when exceeding 128MB heap limit");
|
||||
}
|
||||
|
||||
// --- TypeScript ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typescript_execution() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
interface User { name: string; }
|
||||
Deno.serve(async (req) => {
|
||||
const user: User = { name: "MadBase" };
|
||||
return new Response(`Hello ${user.name}`);
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "Hello MadBase");
|
||||
}
|
||||
|
||||
// --- Environment variables ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_env_vars_accessible() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const val = Deno.env.get("MY_VAR");
|
||||
return new Response(val || "missing");
|
||||
});
|
||||
"#.to_string();
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("MY_VAR".to_string(), "hello_from_env".to_string());
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), env_vars)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "hello_from_env");
|
||||
}
|
||||
|
||||
// --- Fetch API ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_api_available() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
const hasFetch = typeof fetch === 'function';
|
||||
return new Response(JSON.stringify({ hasFetch }));
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, _logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let res: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(res["hasFetch"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
// --- Console log capture ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_console_log_capture() {
|
||||
let runtime = DenoRuntime::new();
|
||||
let code = r#"
|
||||
Deno.serve(async (req) => {
|
||||
console.log("hello from log");
|
||||
console.error("an error");
|
||||
return new Response("ok");
|
||||
});
|
||||
"#.to_string();
|
||||
let (stdout, _stderr, _status, _headers, logs) = runtime
|
||||
.execute(code, None, HashMap::new(), HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stdout, "ok");
|
||||
assert!(logs.len() >= 2, "Should capture at least 2 log entries, got {}", logs.len());
|
||||
let first_log = &logs[0];
|
||||
assert!(first_log.to_string().contains("hello from log"));
|
||||
}
|
||||
|
||||
// --- Worker pool ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_pool_concurrent() {
|
||||
let pool = Arc::new(crate::worker_pool::DenoPool::new(4));
|
||||
let mut handles = vec![];
|
||||
for i in 0..10 {
|
||||
let pool = pool.clone();
|
||||
let code = format!(r#"
|
||||
Deno.serve(async (req) => {{
|
||||
return new Response("result-{i}");
|
||||
}});
|
||||
"#);
|
||||
handles.push(tokio::spawn(async move {
|
||||
pool.execute(code, None, HashMap::new(), HashMap::new()).await
|
||||
}));
|
||||
}
|
||||
let mut success_count = 0;
|
||||
for handle in handles {
|
||||
if let Ok(Ok((stdout, _, _, _, _))) = handle.await {
|
||||
assert!(stdout.starts_with("result-"));
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(success_count, 10, "All 10 concurrent invocations should complete");
|
||||
}
|
||||
|
||||
// --- Transpile unit test ---
|
||||
|
||||
#[test]
|
||||
fn test_transpile_strips_types() {
|
||||
let ts_code = "const x: number = 42; export default x;";
|
||||
let result = DenoRuntime::transpile(ts_code, Path::new("test.ts")).unwrap();
|
||||
assert!(!result.contains(": number"), "Type annotations should be stripped");
|
||||
assert!(result.contains("42"), "Value should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ use std::collections::HashMap;
|
||||
use sqlx::PgPool;
|
||||
use base64::prelude::*;
|
||||
use auth::AuthContext;
|
||||
use common::ProjectContext;
|
||||
use crate::{FunctionsState, models::{DeployRequest, InvokeRequest, InvokeResponse, Function}};
|
||||
|
||||
pub async fn invoke_function(
|
||||
State(state): State<FunctionsState>,
|
||||
db: Option<Extension<PgPool>>,
|
||||
Extension(db): Extension<PgPool>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Extension(project_ctx): Extension<ProjectContext>,
|
||||
Path(name): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<InvokeRequest>,
|
||||
@@ -22,7 +24,6 @@ pub async fn invoke_function(
|
||||
if auth_ctx.role != "authenticated" && auth_ctx.role != "service_role" {
|
||||
return (StatusCode::FORBIDDEN, "Requires authenticated or service_role").into_response();
|
||||
}
|
||||
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
|
||||
|
||||
// Convert headers
|
||||
let mut header_map = HashMap::new();
|
||||
@@ -50,6 +51,26 @@ pub async fn invoke_function(
|
||||
}
|
||||
};
|
||||
|
||||
// 1.5 Fetch Secrets
|
||||
let secrets_rows = sqlx::query("SELECT name, value FROM functions.secrets WHERE project_ref = $1")
|
||||
.bind(&project_ctx.project_ref)
|
||||
.fetch_all(&db)
|
||||
.await;
|
||||
|
||||
let mut env_vars = HashMap::new();
|
||||
if let Ok(rows) = secrets_rows {
|
||||
for row in rows {
|
||||
use sqlx::Row;
|
||||
let name: String = row.get("name");
|
||||
let value: String = row.get("value");
|
||||
env_vars.insert(name, value);
|
||||
}
|
||||
}
|
||||
// Add standard env vars
|
||||
env_vars.insert("SUPABASE_URL".to_string(), format!("http://localhost:{}", std::env::var("WORKER_PORT").unwrap_or_else(|_| "8002".to_string())));
|
||||
env_vars.insert("SUPABASE_ANON_KEY".to_string(), project_ctx.anon_key.unwrap_or_default());
|
||||
env_vars.insert("SUPABASE_SERVICE_ROLE_KEY".to_string(), project_ctx.service_role_key.unwrap_or_default());
|
||||
|
||||
// 2. Execute
|
||||
let result = if func.runtime == "deno" || func.runtime == "typescript" || func.runtime == "javascript" {
|
||||
let code = match String::from_utf8(func.code) {
|
||||
@@ -59,20 +80,20 @@ pub async fn invoke_function(
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid function code".to_string()).into_response();
|
||||
}
|
||||
};
|
||||
state.deno_runtime.execute(code, payload.payload, header_map).await
|
||||
state.deno_pool.execute(code, payload.payload, header_map, env_vars).await
|
||||
} else {
|
||||
// Assume WASM
|
||||
let payload_str = payload.payload.as_ref().map(|v| v.to_string());
|
||||
state.runtime.execute(&func.code, payload_str).await.map(|(out, err)| (out, err, 200, HashMap::new()))
|
||||
state.runtime.execute(&func.code, payload_str).await.map(|(out, err)| (out, err, 200, HashMap::new(), vec![]))
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((stdout, stderr, status, headers)) => {
|
||||
tracing::info!("Function executed successfully. Stdout len: {}, Stderr len: {}", stdout.len(), stderr.len());
|
||||
Ok((stdout, stderr, status, headers, logs)) => {
|
||||
tracing::info!("Function executed successfully. Stdout len: {}, Stderr len: {}, Logs: {}", stdout.len(), stderr.len(), logs.len());
|
||||
let resp = InvokeResponse {
|
||||
result: Some(stdout),
|
||||
error: if stderr.is_empty() { None } else { Some(stderr) },
|
||||
logs: vec![],
|
||||
logs: logs.into_iter().map(|l| l.to_string()).collect(),
|
||||
status,
|
||||
headers: Some(headers),
|
||||
};
|
||||
@@ -86,8 +107,8 @@ pub async fn invoke_function(
|
||||
}
|
||||
|
||||
pub async fn deploy_function(
|
||||
State(state): State<FunctionsState>,
|
||||
db: Option<Extension<PgPool>>,
|
||||
State(_state): State<FunctionsState>,
|
||||
Extension(db): Extension<PgPool>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Json(payload): Json<DeployRequest>,
|
||||
) -> impl IntoResponse {
|
||||
@@ -95,7 +116,6 @@ pub async fn deploy_function(
|
||||
if auth_ctx.role != "service_role" {
|
||||
return (StatusCode::FORBIDDEN, "Deploy requires service_role").into_response();
|
||||
}
|
||||
let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone());
|
||||
|
||||
// Decode base64
|
||||
let code = match BASE64_STANDARD.decode(&payload.code_base64) {
|
||||
@@ -129,3 +149,151 @@ pub async fn deploy_function(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn delete_function(
|
||||
State(_state): State<FunctionsState>,
|
||||
Extension(db): Extension<PgPool>,
|
||||
Extension(auth_ctx): Extension<AuthContext>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!("Deleting function: {}", name);
|
||||
if auth_ctx.role != "service_role" {
|
||||
return (StatusCode::FORBIDDEN, "Delete requires service_role").into_response();
|
||||
}
|
||||
|
||||
let res = sqlx::query("DELETE FROM functions.functions WHERE name = $1")
|
||||
.bind(&name)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(result) => {
|
||||
if result.rows_affected() > 0 {
|
||||
tracing::info!("Function deleted successfully");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
tracing::warn!("Function not found for deletion: {}", name);
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("DB error deleting function: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request as HttpRequest, StatusCode},
|
||||
Router,
|
||||
routing::delete,
|
||||
};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn test_delete_route_exists() {
|
||||
// Verify the delete route is wired in the router
|
||||
let router = crate::router;
|
||||
// If this compiles and the router function uses delete(handlers::delete_function),
|
||||
// then the route exists. This is a compile-time guarantee via lib.rs line 29.
|
||||
let _ = router;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_requires_service_role() {
|
||||
// The handler checks auth_ctx.role != "service_role" and returns 403.
|
||||
// Since the check is at the top of the function before any DB access,
|
||||
// we can verify the role gate logic directly.
|
||||
let anon_ctx = AuthContext { claims: None, role: "anon".to_string() };
|
||||
assert_ne!(anon_ctx.role, "service_role");
|
||||
|
||||
let auth_ctx = AuthContext { claims: None, role: "authenticated".to_string() };
|
||||
assert_ne!(auth_ctx.role, "service_role");
|
||||
|
||||
let service_ctx = AuthContext { claims: None, role: "service_role".to_string() };
|
||||
assert_eq!(service_ctx.role, "service_role");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_rejects_non_service_role() {
|
||||
use axum::middleware;
|
||||
|
||||
async fn inject_anon_auth(
|
||||
mut req: axum::http::Request<Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
req.extensions_mut().insert(AuthContext {
|
||||
claims: None,
|
||||
role: "authenticated".to_string(),
|
||||
});
|
||||
// Also need to inject the pool since it's now mandatory
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://localhost/nonexistent")
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(pool);
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://localhost/nonexistent")
|
||||
.unwrap();
|
||||
|
||||
let config = common::Config {
|
||||
database_url: "postgres://localhost/test".to_string(),
|
||||
redis_url: None,
|
||||
jwt_secret: "a]3kf9!2bx7Lm#Qr8vWnT5pY0gJ6hCdXX".to_string(),
|
||||
port: 8000,
|
||||
google_client_id: None, google_client_secret: None,
|
||||
github_client_id: None, github_client_secret: None,
|
||||
azure_client_id: None, azure_client_secret: None,
|
||||
gitlab_client_id: None, gitlab_client_secret: None,
|
||||
bitbucket_client_id: None, bitbucket_client_secret: None,
|
||||
discord_client_id: None, discord_client_secret: None,
|
||||
redirect_uri: "http://localhost:8000/auth/v1/callback".to_string(),
|
||||
rate_limit_per_second: 10,
|
||||
storage_mode: Default::default(),
|
||||
s3_endpoint: "http://localhost:9000".to_string(),
|
||||
s3_access_key: String::new(), s3_secret_key: String::new(),
|
||||
s3_bucket: "test".to_string(), s3_region: "us-east-1".to_string(),
|
||||
};
|
||||
|
||||
let wasm_rt = std::sync::Arc::new(
|
||||
crate::runtime::WasmRuntime::new().expect("wasm runtime")
|
||||
);
|
||||
let deno_rt = std::sync::Arc::new(crate::deno_runtime::DenoRuntime::new());
|
||||
let deno_pool = std::sync::Arc::new(crate::worker_pool::DenoPool::new(1));
|
||||
|
||||
let state = FunctionsState {
|
||||
db: pool,
|
||||
config,
|
||||
runtime: wasm_rt,
|
||||
deno_runtime: deno_rt,
|
||||
deno_pool,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/:name", delete(delete_function))
|
||||
.layer(middleware::from_fn(inject_anon_auth))
|
||||
.with_state(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
HttpRequest::builder()
|
||||
.method("DELETE")
|
||||
.uri("/my-function")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
routing::post,
|
||||
routing::{post, delete},
|
||||
Router,
|
||||
};
|
||||
use common::Config;
|
||||
@@ -11,6 +11,7 @@ use deno_runtime::DenoRuntime;
|
||||
pub mod handlers;
|
||||
pub mod runtime;
|
||||
pub mod deno_runtime;
|
||||
pub mod worker_pool;
|
||||
pub mod models;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -19,11 +20,13 @@ pub struct FunctionsState {
|
||||
pub config: Config,
|
||||
pub runtime: Arc<WasmRuntime>,
|
||||
pub deno_runtime: Arc<DenoRuntime>,
|
||||
pub deno_pool: Arc<worker_pool::DenoPool>,
|
||||
}
|
||||
|
||||
pub fn router(state: FunctionsState) -> Router {
|
||||
Router::new()
|
||||
.route("/:name", post(handlers::invoke_function))
|
||||
.route("/:name", delete(handlers::delete_function))
|
||||
.route("/", post(handlers::deploy_function))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
66
functions/src/worker_pool.rs
Normal file
66
functions/src/worker_pool.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use crate::deno_runtime::DenoRuntime;
|
||||
|
||||
type FunctionResponse = oneshot::Sender<Result<(String, String, u16, HashMap<String, String>, Vec<Value>)>>;
|
||||
|
||||
pub struct DenoTask {
|
||||
pub code: String,
|
||||
pub payload: Option<Value>,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub response: FunctionResponse,
|
||||
}
|
||||
|
||||
pub struct DenoPool {
|
||||
sender: mpsc::Sender<DenoTask>,
|
||||
}
|
||||
|
||||
impl DenoPool {
|
||||
pub fn new(pool_size: usize) -> Self {
|
||||
let (tx, rx) = mpsc::channel::<DenoTask>(pool_size * 2);
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
|
||||
for _ in 0..pool_size {
|
||||
let rx = rx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local.block_on(&rt, async {
|
||||
loop {
|
||||
let task = {
|
||||
let mut lock = rx.lock().await;
|
||||
lock.recv().await
|
||||
};
|
||||
|
||||
if let Some(task) = task {
|
||||
let result = DenoRuntime::execute_inner(
|
||||
task.code, task.payload, task.headers, task.env_vars
|
||||
).await;
|
||||
let _ = task.response.send(result);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Self { sender: tx }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, code: String, payload: Option<Value>, headers: HashMap<String, String>, env_vars: HashMap<String, String>)
|
||||
-> Result<(String, String, u16, HashMap<String, String>, Vec<Value>)>
|
||||
{
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.sender.send(DenoTask { code, payload, headers, env_vars, response: tx }).await
|
||||
.map_err(|_| anyhow::anyhow!("Worker pool exhausted"))?;
|
||||
rx.await.map_err(|_| anyhow::anyhow!("Worker panicked"))?
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user