diff --git a/.gitignore b/.gitignore
index 5e9a98400..462c66c54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,5 +69,18 @@ node_modules
# Integration test artifacts
test-results/
**/*lcov.info
+
+# Pre-compiled plugin executor (generated at build time)
+plugins/lib/pool-executor.js
+
+# Build executor cache files
+plugins/lib/.build-cache-hash
+plugins/lib/pool-executor.js.sha256
+
+# TypeScript compiled output in plugins (we use ts-node for runtime)
+plugins/**/*.js
+plugins/**/*.js.map
+plugins/**/*.d.ts
+
profraw/
coverage/
diff --git a/Cargo.lock b/Cargo.lock
index a7d040935..584661297 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1476,6 +1476,18 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "async-compression"
version = "0.4.36"
@@ -2834,6 +2846,19 @@ dependencies = [
"winnow 0.6.26",
]
+[[package]]
+name = "crossbeam"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -2862,6 +2887,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -5525,6 +5559,7 @@ dependencies = [
"apalis",
"apalis-cron",
"apalis-redis",
+ "async-channel",
"async-trait",
"aws-config",
"aws-sdk-kms",
@@ -5537,6 +5572,7 @@ dependencies = [
"chrono",
"clap",
"color-eyre",
+ "crossbeam",
"ctor",
"dashmap 6.1.0",
"dotenvy",
diff --git a/Cargo.toml b/Cargo.toml
index 8eb7375de..990356098 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -65,6 +65,8 @@ hmac = { version = "0.12" }
sha2 = { version = "0.10" }
sha3 = { version = "0.10" }
dashmap = { version = "6.1" }
+crossbeam = { version = "0.8.4" }
+async-channel = "2.5"
actix-governor = "0.8"
solana-sdk = { version = "3" }
solana-client = { version = "3" }
diff --git a/build.rs b/build.rs
new file mode 100644
index 000000000..efb0b1321
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,87 @@
+use std::path::Path;
+use std::process::Command;
+
+fn main() {
+ let plugins_dir = Path::new("plugins");
+ if !plugins_dir.exists() {
+ println!("cargo:warning=plugins directory not found, skipping plugin setup");
+ return;
+ }
+
+ let node_modules = plugins_dir.join("node_modules");
+ let pool_executor_ts = plugins_dir.join("lib/pool-executor.ts");
+ let pool_executor_js = plugins_dir.join("lib/pool-executor.js");
+
+ // Tell Cargo when to rerun this script
+ println!("cargo:rerun-if-changed=plugins/lib/pool-executor.ts");
+ println!("cargo:rerun-if-changed=plugins/package.json");
+
+ // Check if pnpm is available
+ let pnpm_check = Command::new("pnpm").arg("--version").output();
+ if pnpm_check.is_err() {
+ println!("cargo:warning=pnpm not found in PATH, skipping plugin setup");
+ println!("cargo:warning=Run 'pnpm install && pnpm run build' in plugins/ manually");
+ return;
+ }
+
+ // Only run pnpm install if node_modules is missing
+ if !node_modules.exists() {
+ println!("Installing plugin dependencies...");
+ let output = Command::new("pnpm")
+ .arg("install")
+ .arg("--ignore-scripts") // Skip postinstall, we'll build explicitly below
+ .current_dir(plugins_dir)
+ .output();
+
+ match output {
+ Ok(output) if output.status.success() => {
+ println!("✓ pnpm install completed");
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ println!("cargo:warning=pnpm install failed: {stderr}");
+ return;
+ }
+ Err(e) => {
+ println!("cargo:warning=Failed to execute pnpm install: {e}");
+ return;
+ }
+ }
+ }
+
+ // Build pool-executor if source is newer than output (or output missing)
+ let needs_build = if !pool_executor_js.exists() {
+ true
+ } else {
+ // Compare modification times
+ match (
+ pool_executor_ts.metadata().and_then(|m| m.modified()),
+ pool_executor_js.metadata().and_then(|m| m.modified()),
+ ) {
+ (Ok(src_time), Ok(out_time)) => src_time > out_time,
+ _ => true, // Rebuild if we can't determine
+ }
+ };
+
+ if needs_build {
+ println!("Building executor...");
+ let output = Command::new("pnpm")
+ .arg("run")
+ .arg("build")
+ .current_dir(plugins_dir)
+ .output();
+
+ match output {
+ Ok(output) if output.status.success() => {
+ println!("✓ executor built successfully");
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ println!("cargo:warning=executor build failed: {stderr}");
+ }
+ Err(e) => {
+ println!("cargo:warning=Failed to build executor: {e}");
+ }
+ }
+ }
+}
diff --git a/docs/plugins/index.mdx b/docs/plugins/index.mdx
index b57071a37..84f3acaed 100644
--- a/docs/plugins/index.mdx
+++ b/docs/plugins/index.mdx
@@ -716,3 +716,208 @@ The relayer will automatically detect which pattern your plugin uses:
- If neither → shows clear error message
This ensures a smooth transition period where both patterns work simultaneously.
+
+## Performance Tuning
+
+For production deployments handling high concurrency, the plugin system offers extensive tuning options via environment variables.
+
+### Execution Modes
+
+The plugin system supports two execution modes:
+
+| Mode | Environment Variable | Description |
+|------|---------------------|-------------|
+| **Pool mode** (default) | `PLUGIN_USE_POOL=true` | Uses persistent worker pool. Best performance. |
+| **ts-node mode** | `PLUGIN_USE_POOL=false` | Spawns a new process per request. Simpler but slower. |
+
+
+Pool mode is enabled by default since v1.4. It significantly improves performance by reusing worker processes and maintaining persistent connections. Use `PLUGIN_USE_POOL=false` only for debugging or if you encounter issues.
+
+
+### Environment Variables Reference
+
+#### Simple Scaling (Recommended)
+
+
+**Most users only need one variable!** Set `PLUGIN_MAX_CONCURRENCY` and everything else is auto-calculated.
+
+
+```bash
+# This is all you need for 3000 concurrent users:
+export PLUGIN_MAX_CONCURRENCY=3000
+```
+
+The system automatically derives on **both Rust and Node.js sides**:
+
+**Rust side (connection pool & queue):**
+- `PLUGIN_POOL_MAX_CONNECTIONS` = 3000 (1:1 ratio)
+- `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` = 4500 (1.5x for headroom)
+- `PLUGIN_POOL_MAX_QUEUE_SIZE` = 6000 (2x for burst handling)
+- `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` = auto-scaled based on workload per thread (500-1000ms)
+
+**Node.js side (worker pool):**
+- `PLUGIN_POOL_MAX_THREADS` = memory-aware scaling (see below), capped at 32
+- `PLUGIN_POOL_CONCURRENT_TASKS` = (concurrency / maxThreads) × 1.2, capped at 250
+- `PLUGIN_WORKER_HEAP_MB` = 512 + (concurrent_tasks × 5), between 1024-2048MB
+
+#### Advanced Overrides (Power Users)
+
+You can override any auto-derived value when needed:
+
+```bash
+# Set the primary scaling knob
+export PLUGIN_MAX_CONCURRENCY=3000
+
+# Override just the queue size (other values still auto-derived)
+export PLUGIN_POOL_MAX_QUEUE_SIZE=10000
+```
+
+#### All Available Variables
+
+| Variable | Default | Auto-Derived From | Description |
+|----------|---------|-------------------|-------------|
+| `PLUGIN_MAX_CONCURRENCY` | 2048 | - | **Primary scaling knob** - sets expected concurrent load |
+| **Rust Side** ||||
+| `PLUGIN_POOL_MAX_CONNECTIONS` | 2048 | `MAX_CONCURRENCY` | Max connections to pool server |
+| `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` | 4096 | `MAX_CONCURRENCY × 1.5` | Max plugin connections to relayer |
+| `PLUGIN_POOL_MAX_QUEUE_SIZE` | 4096 | `MAX_CONCURRENCY × 2` | Max queued requests |
+| `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` | 500 | Workload-based (500-1000ms) | Wait time when queue is full |
+| `PLUGIN_POOL_CONNECT_RETRIES` | 15 | - | Retry attempts when connecting |
+| `PLUGIN_POOL_REQUEST_TIMEOUT_SECS` | 30 | - | Timeout for pool requests |
+| `PLUGIN_SOCKET_IDLE_TIMEOUT_SECS` | 60 | - | Close idle connections after |
+| `PLUGIN_SOCKET_READ_TIMEOUT_SECS` | 30 | - | Read timeout per message |
+| `PLUGIN_POOL_WORKERS` | auto | CPU cores | Queue processing workers |
+| `PLUGIN_POOL_SOCKET_BACKLOG` | 2048 | `MAX_CONCURRENCY` | Socket connection backlog |
+| **Node.js Side** ||||
+| `PLUGIN_POOL_MIN_THREADS` | auto | `max(2, cpuCount/2)` | Minimum worker threads |
+| `PLUGIN_POOL_MAX_THREADS` | auto | Memory-aware (capped at 32) | Worker threads in pool |
+| `PLUGIN_POOL_CONCURRENT_TASKS` | auto | `(concurrency/threads) × 1.2` | Tasks per worker (max 250) |
+| `PLUGIN_WORKER_HEAP_MB` | auto | `512 + (tasks × 5)` | Worker heap size (1024-2048MB) |
+| `PLUGIN_POOL_IDLE_TIMEOUT` | 60000 | - | Worker idle timeout (ms) |
+
+#### Memory-Aware Thread Scaling
+
+The plugin system automatically scales worker threads based on **both concurrency requirements AND available system memory**. This prevents out-of-memory issues on systems with limited RAM.
+
+**How it works:**
+1. **Memory budget**: Uses 50% of system RAM for worker threads
+2. **Per-worker allocation**: ~1GB heap budget per worker thread
+3. **Concurrency scaling**: `concurrency / 200` threads (each thread handles ~200 concurrent requests via async I/O)
+4. **Final calculation**: `min(memory_based, concurrency_based)`, capped at 32 threads
+
+**Examples:**
+
+| System RAM | Max Concurrency | Memory-Based | Concurrency-Based | Final Threads |
+|------------|-----------------|--------------|-------------------|---------------|
+| 16GB | 1000 | 8 | 5 | 5 |
+| 16GB | 5000 | 8 | 25 | 8 |
+| 32GB | 5000 | 16 | 25 | 16 |
+| 64GB | 10000 | 32 | 50 | 32 |
+
+
+This prevents the previous issue where high concurrency (e.g., 5000 VUs) would spawn too many threads, causing excessive memory pressure and GC pauses.
+
+
+#### Queue Timeout Auto-Scaling
+
+The queue send timeout (`PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS`) automatically scales based on workload per thread:
+
+| Workload per Thread | Timeout |
+|---------------------|---------|
+| > 100 items/thread | 1000ms (heavy load) |
+| 50-100 items/thread | 750ms (medium load) |
+| < 50 items/thread | 500ms (light load) |
+
+This ensures requests have sufficient time to queue during traffic spikes while maintaining responsiveness under normal load.
+
+#### Timeout Alignment
+
+
+Timeouts must be aligned! If your plugin takes up to 120s, set these accordingly.
+
+
+```bash
+# In config.json: "timeout": 120
+
+# Environment should match:
+export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=120
+export PLUGIN_SOCKET_IDLE_TIMEOUT_SECS=180 # 1.5x plugin timeout
+```
+
+#### Health & Recovery
+
+Controls automatic health monitoring and recovery.
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS` | 5 | Minimum seconds between health checks |
+| `PLUGIN_TRACE_TIMEOUT_MS` | 100 | Timeout for collecting execution traces |
+
+### Load Profile Examples
+
+#### Low Load (< 100 concurrent requests)
+
+```bash
+# Default settings are sufficient - pool mode is enabled automatically
+```
+
+#### Medium Load (100-1000 concurrent requests)
+
+```bash
+export PLUGIN_MAX_CONCURRENCY=1000
+```
+
+#### High Load (1000-5000 concurrent requests)
+
+```bash
+export PLUGIN_MAX_CONCURRENCY=3000
+export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=60
+```
+
+#### Extreme Load (5000+ concurrent requests)
+
+```bash
+export PLUGIN_MAX_CONCURRENCY=8000
+export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=120
+export PLUGIN_POOL_CONNECT_RETRIES=20
+```
+
+### Troubleshooting Common Errors
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| `Plugin execution queue is full` | More requests than queue can hold | Increase `PLUGIN_POOL_MAX_QUEUE_SIZE` and `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` |
+| `Connection limit reached` | Too many concurrent plugin connections | Increase `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` |
+| `Failed to connect to pool after N attempts` | Pool server overwhelmed | Increase `PLUGIN_POOL_CONNECT_RETRIES` and `PLUGIN_POOL_MAX_CONNECTIONS` |
+| `ScriptTimeout(N)` | Plugin execution exceeded timeout | Increase `timeout` in plugin config (config.json) |
+| `All connection permits exhausted` | Connection pool at capacity | Increase `PLUGIN_POOL_MAX_CONNECTIONS` |
+| `FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory` | Worker heap too small | Increase `PLUGIN_WORKER_HEAP_MB` or reduce `PLUGIN_POOL_CONCURRENT_TASKS` |
+| `Pool server crashed and restarting` | Memory pressure or GC issues | Check logs for heap usage; reduce `PLUGIN_MAX_CONCURRENCY` or increase system RAM |
+
+
+When increasing limits significantly, monitor your system's memory and CPU usage. Each connection consumes resources.
+
+
+## Architecture & Internals
+
+For developers who want to understand or modify the plugin system internals, see the [Architecture Guide](https://github.com/OpenZeppelin/openzeppelin-relayer/blob/main/plugins/ARCHITECTURE.md).
+
+The architecture documentation covers:
+
+- **System Architecture**: High-level diagram showing Rust ↔ Node.js communication
+- **Module Overview**: Purpose of each Rust and TypeScript module
+- **Communication Protocols**: JSON-line protocol for pool and shared socket communication
+- **Request Flow**: Step-by-step execution path for plugin requests
+- **Health & Recovery**: Circuit breaker states, dead server detection, memory pressure handling
+- **Module Dependencies**: How the codebase modules relate to each other
+
+### Key Source Files
+
+| Component | Location | Description |
+|-----------|----------|-------------|
+| **Rust Plugin Runner** | `src/services/plugins/runner.rs` | Entry point for plugin execution |
+| **Pool Executor** | `src/services/plugins/pool_executor.rs` | Manages Node.js process and connections |
+| **Configuration** | `src/services/plugins/config.rs` | Auto-derivation logic for all env vars |
+| **Pool Server** | `plugins/lib/pool-server.ts` | Node.js server accepting plugin requests |
+| **Executor** | `plugins/lib/pool-executor.ts` | Plugin execution |
+| **Plugin SDK** | `plugins/lib/plugin.ts` | PluginContext and API for plugins |
diff --git a/examples/basic-example-plugin/test-plugin/package.json b/examples/basic-example-plugin/test-plugin/package.json
index f27c9f4a9..24f3acad8 100644
--- a/examples/basic-example-plugin/test-plugin/package.json
+++ b/examples/basic-example-plugin/test-plugin/package.json
@@ -13,7 +13,7 @@
"author": "",
"license": "",
"dependencies": {
- "@openzeppelin/relayer-sdk": "^1.6.0",
+ "@openzeppelin/relayer-sdk": "^1.8.0",
"@types/node": "^24.0.3",
"ethers": "^6.14.3",
"uuid": "^11.1.0"
diff --git a/examples/basic-example-plugin/test-plugin/pnpm-lock.yaml b/examples/channels-plugin-example/channel/pnpm-lock.yaml
similarity index 72%
rename from examples/basic-example-plugin/test-plugin/pnpm-lock.yaml
rename to examples/channels-plugin-example/channel/pnpm-lock.yaml
index 81f3e65a1..1e028f30d 100644
--- a/examples/basic-example-plugin/test-plugin/pnpm-lock.yaml
+++ b/examples/channels-plugin-example/channel/pnpm-lock.yaml
@@ -6,22 +6,19 @@ settings:
importers:
.:
dependencies:
+ '@openzeppelin/relayer-plugin-channels':
+ specifier: ^0.6.0
+ version: 0.6.0
'@openzeppelin/relayer-sdk':
- specifier: ^1.6.0
- version: 1.6.0
- '@types/node':
- specifier: ^24.0.3
- version: 24.2.0
- ethers:
- specifier: ^6.14.3
- version: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
- uuid:
- specifier: ^11.1.0
- version: 11.1.0
+ specifier: ^1.9.0
+ version: 1.9.0
devDependencies:
'@types/jest':
specifier: ^29.5.12
version: 29.5.14
+ '@types/node':
+ specifier: ^24.0.3
+ version: 24.10.9
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
@@ -30,62 +27,64 @@ importers:
version: 9.1.7
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@24.2.0)
+ version: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
ts-jest:
specifier: ^29.1.2
- version: 29.4.1(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.2.0))(typescript@5.9.2)
+ version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)))(typescript@5.9.3)
+ ts-node:
+ specifier: ^10.9.2
+ version: 10.9.2(@types/node@24.10.9)(typescript@5.9.3)
typescript:
specifier: ^5.3.3
- version: 5.9.2
+ version: 5.9.3
packages:
- '@adraffy/ens-normalize@1.10.1':
- resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
- '@ampproject/remapping@2.3.0':
- resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
- engines: {node: '>=6.0.0'}
- '@babel/code-frame@7.27.1':
- resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ '@actions/exec@1.1.1':
+ resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
+ '@actions/io@1.1.3':
+ resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
+ '@babel/code-frame@7.28.6':
+ resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'}
- '@babel/compat-data@7.28.0':
- resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==}
+ '@babel/compat-data@7.28.6':
+ resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
engines: {node: '>=6.9.0'}
- '@babel/core@7.28.0':
- resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==}
+ '@babel/core@7.28.6':
+ resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
engines: {node: '>=6.9.0'}
- '@babel/generator@7.28.0':
- resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==}
+ '@babel/generator@7.28.6':
+ resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-compilation-targets@7.27.2':
- resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-imports@7.27.1':
- resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-module-transforms@7.27.3':
- resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==}
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
- '@babel/helper-plugin-utils@7.27.1':
- resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-identifier@7.27.1':
- resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
- '@babel/helpers@7.28.2':
- resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==}
+ '@babel/helpers@7.28.6':
+ resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.28.0':
- resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==}
+ '@babel/parser@7.28.6':
+ resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-syntax-async-generators@7.8.4':
@@ -105,8 +104,8 @@ packages:
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-import-attributes@7.27.1':
- resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==}
+ '@babel/plugin-syntax-import-attributes@7.28.6':
+ resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -118,8 +117,8 @@ packages:
resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-jsx@7.27.1':
- resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
+ '@babel/plugin-syntax-jsx@7.28.6':
+ resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -157,22 +156,25 @@ packages:
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-typescript@7.27.1':
- resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
+ '@babel/plugin-syntax-typescript@7.28.6':
+ resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/template@7.27.2':
- resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.28.0':
- resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==}
+ '@babel/traverse@7.28.6':
+ resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.28.2':
- resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
+ '@babel/types@7.28.6':
+ resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@cspotcode/source-map-support@0.8.1':
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
'@istanbuljs/load-nyc-config@1.1.0':
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'}
@@ -231,22 +233,30 @@ packages:
'@jest/types@29.6.3':
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
- '@jridgewell/gen-mapping@0.3.12':
- resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
- '@jridgewell/sourcemap-codec@1.5.4':
- resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
- '@jridgewell/trace-mapping@0.3.29':
- resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
- '@noble/curves@1.2.0':
- resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
- '@noble/hashes@1.3.2':
- resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
- engines: {node: '>= 16'}
- '@openzeppelin/relayer-sdk@1.6.0':
- resolution: {integrity: sha512-UjL4TLz4tvCY1ssE3wf47FU+/2vE/bJ5HoXyufUsidL5bRXwf6ziwWZWUa0YsPiGEQNdVtsuoUR9qqHjxYqlXg==}
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@jridgewell/trace-mapping@0.3.9':
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@noble/curves@1.9.7':
+ resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
+ engines: {node: ^14.21.3 || >=16}
+ '@noble/hashes@1.8.0':
+ resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
+ engines: {node: ^14.21.3 || >=16}
+ '@openzeppelin/relayer-plugin-channels@0.6.0':
+ resolution: {integrity: sha512-vqNi6TXuu7LNbc1NoGfUD9vh+egBMB/vhwf03idzWhKPdNLVUzGE+PV7fzQNjHkpbTROs+d7vnBEdA8CJxo5gQ==}
+ engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm}
+ '@openzeppelin/relayer-sdk@1.9.0':
+ resolution: {integrity: sha512-G3Zhg0imaT9Ej1cE9Cq5d4uQvW+DinD2GvRuiPWo3+O9KI8ivBnGJkjEGgK9Ja3/QsUNIfx8Gk5Mdw2sPXjVWA==}
engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -254,6 +264,22 @@ packages:
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
+ '@stellar/js-xdr@3.1.2':
+ resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==}
+ '@stellar/stellar-base@14.0.4':
+ resolution: {integrity: sha512-UbNW6zbdOBXJwLAV2mMak0bIC9nw3IZVlQXkv2w2dk1jgCbJjy3oRVC943zeGE5JAm0Z9PHxrIjmkpGhayY7kw==}
+ engines: {node: '>=20.0.0'}
+ '@stellar/stellar-sdk@14.4.3':
+ resolution: {integrity: sha512-QfaScSNd4Ku0GGfaZjR8679+M5gLHG+09OLLqV3Bv1VaDKXjHmhf8ikalz2jlx3oFnmlEpEgnqXIdf4kdD2x/w==}
+ engines: {node: '>=20.0.0'}
+ '@tsconfig/node10@1.0.12':
+ resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
+ '@tsconfig/node12@1.0.11':
+ resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
+ '@tsconfig/node14@1.0.3':
+ resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
+ '@tsconfig/node16@1.0.4':
+ resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
'@types/babel__generator@7.27.0':
@@ -272,20 +298,23 @@ packages:
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/jest@29.5.14':
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
- '@types/node@22.7.5':
- resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
- '@types/node@24.2.0':
- resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
+ '@types/node@24.10.9':
+ resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
- '@types/yargs@17.0.33':
- resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==}
- aes-js@4.0.0-beta.5:
- resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
+ '@types/yargs@17.0.35':
+ resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
+ acorn-walk@8.3.4:
+ resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
+ engines: {node: '>=0.4.0'}
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
@@ -301,12 +330,17 @@ packages:
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ arg@4.1.3:
+ resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
- axios@1.11.0:
- resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+ axios@1.13.2:
+ resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -329,13 +363,23 @@ packages:
'@babel/core': ^7.0.0
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ base32.js@0.1.0:
+ resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==}
+ engines: {node: '>=0.12.0'}
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ baseline-browser-mapping@2.9.14:
+ resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
+ hasBin: true
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
- browserslist@4.25.1:
- resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==}
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bs-logger@0.2.6:
@@ -345,12 +389,17 @@ packages:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
- bufferutil@4.0.9:
- resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==}
- engines: {node: '>=6.14.2'}
+ buffer@6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
+ call-bind@1.0.8:
+ resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
+ engines: {node: '>= 0.4'}
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -360,8 +409,8 @@ packages:
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
- caniuse-lite@1.0.30001731:
- resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
+ caniuse-lite@1.0.30001764:
+ resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -379,8 +428,8 @@ packages:
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
- collect-v8-coverage@1.0.2:
- resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==}
+ collect-v8-coverage@1.0.3:
+ resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -397,19 +446,21 @@ packages:
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
+ create-require@1.1.1:
+ resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
- debug@4.4.1:
- resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
- dedent@1.6.0:
- resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==}
+ dedent@1.7.1:
+ resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
@@ -418,6 +469,9 @@ packages:
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -427,18 +481,21 @@ packages:
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ diff@4.0.2:
+ resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+ engines: {node: '>=0.3.1'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
- electron-to-chromium@1.5.194:
- resolution: {integrity: sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==}
+ electron-to-chromium@1.5.267:
+ resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
- error-ex@1.3.2:
- resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+ error-ex@1.3.4:
+ resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -461,9 +518,9 @@ packages:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
- ethers@6.15.0:
- resolution: {integrity: sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==}
- engines: {node: '>=14.0.0'}
+ eventsource@2.0.2:
+ resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
+ engines: {node: '>=12.0.0'}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -477,6 +534,8 @@ packages:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
+ feaxios@0.0.23:
+ resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -491,8 +550,11 @@ packages:
peerDependenciesMeta:
debug:
optional: true
- form-data@4.0.4:
- resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+ form-data@4.0.5:
+ resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -536,6 +598,8 @@ packages:
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -554,6 +618,8 @@ packages:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
import-local@3.2.0:
resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==}
engines: {node: '>=8'}
@@ -568,6 +634,9 @@ packages:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@@ -580,9 +649,17 @@ packages:
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-retry-allowed@3.0.0:
+ resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==}
+ engines: {node: '>=12'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
@@ -600,8 +677,8 @@ packages:
istanbul-lib-source-maps@4.0.1:
resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
engines: {node: '>=10'}
- istanbul-reports@3.1.7:
- resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==}
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jest-changed-files@29.7.0:
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
@@ -708,8 +785,8 @@ packages:
optional: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@3.14.1:
- resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
+ js-yaml@3.14.2:
+ resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
@@ -770,13 +847,10 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
- node-gyp-build@4.8.4:
- resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
- hasBin: true
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
- node-releases@2.0.19:
- resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -825,6 +899,9 @@ packages:
pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -835,6 +912,8 @@ packages:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+ randombytes@2.1.0:
+ resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
require-directory@2.1.1:
@@ -849,17 +928,26 @@ packages:
resolve.exports@2.0.3:
resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==}
engines: {node: '>=10'}
- resolve@1.22.10:
- resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+ resolve@1.22.11:
+ resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.7.2:
- resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+ sha.js@2.4.12:
+ resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
+ engines: {node: '>= 0.10'}
+ hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -915,11 +1003,16 @@ packages:
engines: {node: '>=8'}
tmpl@1.0.5:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
+ to-buffer@1.2.2:
+ resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
+ engines: {node: '>= 0.4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
- ts-jest@29.4.1:
- resolution: {integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==}
+ toml@3.0.0:
+ resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
+ ts-jest@29.4.6:
+ resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -944,8 +1037,19 @@ packages:
optional: true
jest-util:
optional: true
- tslib@2.7.0:
- resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
+ ts-node@10.9.2:
+ resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
+ hasBin: true
+ peerDependencies:
+ '@swc/core': '>=1.2.50'
+ '@swc/wasm': '>=1.2.50'
+ '@types/node': '*'
+ typescript: '>=2.7'
+ peerDependenciesMeta:
+ '@swc/core':
+ optional: true
+ '@swc/wasm':
+ optional: true
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
@@ -955,34 +1059,36 @@ packages:
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
- typescript@5.9.2:
- resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
- undici-types@6.19.8:
- resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
- undici-types@7.10.0:
- resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
- update-browserslist-db@1.1.3:
- resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
- utf-8-validate@5.0.10:
- resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
- engines: {node: '>=6.14.2'}
- uuid@11.1.0:
- resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
- hasBin: true
+ urijs@1.19.11:
+ resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==}
+ v8-compile-cache-lib@3.0.1:
+ resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
v8-to-istanbul@9.3.0:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
+ which-typed-array@1.1.20:
+ resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
+ engines: {node: '>= 0.4'}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -997,17 +1103,6 @@ packages:
write-file-atomic@4.0.2:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
- ws@8.17.1:
- resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- bufferutil: ^4.0.1
- utf-8-validate: '>=5.0.2'
- peerDependenciesMeta:
- bufferutil:
- optional: true
- utf-8-validate:
- optional: true
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -1019,200 +1114,205 @@ packages:
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yn@3.1.1:
+ resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+ engines: {node: '>=6'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
snapshots:
- '@adraffy/ens-normalize@1.10.1': {}
- '@ampproject/remapping@2.3.0':
+ '@actions/exec@1.1.1':
dependencies:
- '@jridgewell/gen-mapping': 0.3.12
- '@jridgewell/trace-mapping': 0.3.29
- '@babel/code-frame@7.27.1':
+ '@actions/io': 1.1.3
+ '@actions/io@1.1.3': {}
+ '@babel/code-frame@7.28.6':
dependencies:
- '@babel/helper-validator-identifier': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
- '@babel/compat-data@7.28.0': {}
- '@babel/core@7.28.0':
- dependencies:
- '@ampproject/remapping': 2.3.0
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.0
- '@babel/helper-compilation-targets': 7.27.2
- '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
- '@babel/helpers': 7.28.2
- '@babel/parser': 7.28.0
- '@babel/template': 7.27.2
- '@babel/traverse': 7.28.0
- '@babel/types': 7.28.2
+ '@babel/compat-data@7.28.6': {}
+ '@babel/core@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
+ '@babel/helpers': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.1
+ debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
- '@babel/generator@7.28.0':
+ '@babel/generator@7.28.6':
dependencies:
- '@babel/parser': 7.28.0
- '@babel/types': 7.28.2
- '@jridgewell/gen-mapping': 0.3.12
- '@jridgewell/trace-mapping': 0.3.29
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
- '@babel/helper-compilation-targets@7.27.2':
+ '@babel/helper-compilation-targets@7.28.6':
dependencies:
- '@babel/compat-data': 7.28.0
+ '@babel/compat-data': 7.28.6
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.25.1
+ browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-globals@7.28.0': {}
- '@babel/helper-module-imports@7.27.1':
+ '@babel/helper-module-imports@7.28.6':
dependencies:
- '@babel/traverse': 7.28.0
- '@babel/types': 7.28.2
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)':
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-module-imports': 7.27.1
- '@babel/helper-validator-identifier': 7.27.1
- '@babel/traverse': 7.28.0
+ '@babel/core': 7.28.6
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.6
transitivePeerDependencies:
- supports-color
- '@babel/helper-plugin-utils@7.27.1': {}
+ '@babel/helper-plugin-utils@7.28.6': {}
'@babel/helper-string-parser@7.27.1': {}
- '@babel/helper-validator-identifier@7.27.1': {}
+ '@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-option@7.27.1': {}
- '@babel/helpers@7.28.2':
+ '@babel/helpers@7.28.6':
dependencies:
- '@babel/template': 7.27.2
- '@babel/types': 7.28.2
- '@babel/parser@7.28.0':
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+ '@babel/parser@7.28.6':
dependencies:
- '@babel/types': 7.28.2
- '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.0)':
+ '@babel/types': 7.28.6
+ '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)':
dependencies:
- '@babel/core': 7.28.0
- '@babel/helper-plugin-utils': 7.27.1
- '@babel/template@7.27.2':
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/template@7.28.6':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/parser': 7.28.0
- '@babel/types': 7.28.2
- '@babel/traverse@7.28.0':
+ '@babel/code-frame': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@babel/traverse@7.28.6':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.0
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.28.0
- '@babel/template': 7.27.2
- '@babel/types': 7.28.2
- debug: 4.4.1
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
- '@babel/types@7.28.2':
+ '@babel/types@7.28.6':
dependencies:
'@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@0.2.3': {}
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
'@istanbuljs/load-nyc-config@1.1.0':
dependencies:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
- js-yaml: 3.14.1
+ js-yaml: 3.14.2
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.3': {}
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
slash: 3.0.0
- '@jest/core@29.7.0':
+ '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@24.2.0)
+ jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -1236,7 +1336,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
jest-mock: 29.7.0
'@jest/expect-utils@29.7.0':
dependencies:
@@ -1251,7 +1351,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -1270,10 +1370,10 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@jridgewell/trace-mapping': 0.3.29
- '@types/node': 24.2.0
+ '@jridgewell/trace-mapping': 0.3.31
+ '@types/node': 24.10.9
chalk: 4.1.2
- collect-v8-coverage: 1.0.2
+ collect-v8-coverage: 1.0.3
exit: 0.1.2
glob: 7.2.3
graceful-fs: 4.2.11
@@ -1281,7 +1381,7 @@ snapshots:
istanbul-lib-instrument: 6.0.3
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 4.0.1
- istanbul-reports: 3.1.7
+ istanbul-reports: 3.2.0
jest-message-util: 29.7.0
jest-util: 29.7.0
jest-worker: 29.7.0
@@ -1296,7 +1396,7 @@ snapshots:
'@sinclair/typebox': 0.27.8
'@jest/source-map@29.6.3':
dependencies:
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/trace-mapping': 0.3.31
callsites: 3.1.0
graceful-fs: 4.2.11
'@jest/test-result@29.7.0':
@@ -1304,7 +1404,7 @@ snapshots:
'@jest/console': 29.7.0
'@jest/types': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
- collect-v8-coverage: 1.0.2
+ collect-v8-coverage: 1.0.3
'@jest/test-sequencer@29.7.0':
dependencies:
'@jest/test-result': 29.7.0
@@ -1313,9 +1413,9 @@ snapshots:
slash: 3.0.0
'@jest/transform@29.7.0':
dependencies:
- '@babel/core': 7.28.0
+ '@babel/core': 7.28.6
'@jest/types': 29.6.3
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/trace-mapping': 0.3.31
babel-plugin-istanbul: 6.1.1
chalk: 4.1.2
convert-source-map: 2.0.0
@@ -1335,26 +1435,42 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 24.2.0
- '@types/yargs': 17.0.33
+ '@types/node': 24.10.9
+ '@types/yargs': 17.0.35
chalk: 4.1.2
- '@jridgewell/gen-mapping@0.3.12':
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+ '@jridgewell/remapping@2.3.5':
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.4
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
- '@jridgewell/sourcemap-codec@1.5.4': {}
- '@jridgewell/trace-mapping@0.3.29':
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.4
- '@noble/curves@1.2.0':
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping@0.3.9':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@noble/curves@1.9.7':
+ dependencies:
+ '@noble/hashes': 1.8.0
+ '@noble/hashes@1.8.0': {}
+ '@openzeppelin/relayer-plugin-channels@0.6.0':
dependencies:
- '@noble/hashes': 1.3.2
- '@noble/hashes@1.3.2': {}
- '@openzeppelin/relayer-sdk@1.6.0':
+ '@actions/exec': 1.1.1
+ '@openzeppelin/relayer-sdk': 1.9.0
+ '@stellar/stellar-sdk': 14.4.3
+ axios: 1.13.2
+ transitivePeerDependencies:
+ - debug
+ '@openzeppelin/relayer-sdk@1.9.0':
dependencies:
- axios: 1.11.0
+ axios: 1.13.2
transitivePeerDependencies:
- debug
'@sinclair/typebox@0.27.8': {}
@@ -1364,26 +1480,51 @@ snapshots:
'@sinonjs/fake-timers@10.3.0':
dependencies:
'@sinonjs/commons': 3.0.1
+ '@stellar/js-xdr@3.1.2': {}
+ '@stellar/stellar-base@14.0.4':
+ dependencies:
+ '@noble/curves': 1.9.7
+ '@stellar/js-xdr': 3.1.2
+ base32.js: 0.1.0
+ bignumber.js: 9.3.1
+ buffer: 6.0.3
+ sha.js: 2.4.12
+ '@stellar/stellar-sdk@14.4.3':
+ dependencies:
+ '@stellar/stellar-base': 14.0.4
+ axios: 1.13.2
+ bignumber.js: 9.3.1
+ eventsource: 2.0.2
+ feaxios: 0.0.23
+ randombytes: 2.1.0
+ toml: 3.0.0
+ urijs: 1.19.11
+ transitivePeerDependencies:
+ - debug
+ '@tsconfig/node10@1.0.12': {}
+ '@tsconfig/node12@1.0.11': {}
+ '@tsconfig/node14@1.0.3': {}
+ '@tsconfig/node16@1.0.4': {}
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.0
- '@babel/types': 7.28.2
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.28.2
+ '@babel/types': 7.28.6
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.28.0
- '@babel/types': 7.28.2
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.28.2
+ '@babel/types': 7.28.6
'@types/graceful-fs@4.1.9':
dependencies:
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
dependencies:
@@ -1395,19 +1536,19 @@ snapshots:
dependencies:
expect: 29.7.0
pretty-format: 29.7.0
- '@types/node@22.7.5':
- dependencies:
- undici-types: 6.19.8
- '@types/node@24.2.0':
+ '@types/node@24.10.9':
dependencies:
- undici-types: 7.10.0
+ undici-types: 7.16.0
'@types/stack-utils@2.0.3': {}
'@types/uuid@10.0.0': {}
'@types/yargs-parser@21.0.3': {}
- '@types/yargs@17.0.33':
+ '@types/yargs@17.0.35':
dependencies:
'@types/yargs-parser': 21.0.3
- aes-js@4.0.0-beta.5: {}
+ acorn-walk@8.3.4:
+ dependencies:
+ acorn: 8.15.0
+ acorn@8.15.0: {}
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
@@ -1420,24 +1561,28 @@ snapshots:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
+ arg@4.1.3: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
asynckit@0.4.0: {}
- axios@1.11.0:
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+ axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
- form-data: 4.0.4
+ form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
- babel-jest@29.7.0(@babel/core@7.28.0):
+ babel-jest@29.7.0(@babel/core@7.28.6):
dependencies:
- '@babel/core': 7.28.0
+ '@babel/core': 7.28.6
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.5
babel-plugin-istanbul: 6.1.1
- babel-preset-jest: 29.6.3(@babel/core@7.28.0)
+ babel-preset-jest: 29.6.3(@babel/core@7.28.6)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
@@ -1445,7 +1590,7 @@ snapshots:
- supports-color
babel-plugin-istanbul@6.1.1:
dependencies:
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.28.6
'@istanbuljs/load-nyc-config': 1.1.0
'@istanbuljs/schema': 0.1.3
istanbul-lib-instrument: 5.2.1
@@ -1454,34 +1599,38 @@ snapshots:
- supports-color
babel-plugin-jest-hoist@29.6.3:
dependencies:
- '@babel/template': 7.27.2
- '@babel/types': 7.28.2
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
- babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.0):
- dependencies:
- '@babel/core': 7.28.0
- '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.0)
- '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.0)
- '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.0)
- '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.0)
- '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.0)
- '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.0)
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.0)
- '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.0)
- '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.0)
- '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.0)
- babel-preset-jest@29.6.3(@babel/core@7.28.0):
- dependencies:
- '@babel/core': 7.28.0
+ babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.6):
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.6)
+ '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.6)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.6)
+ '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.28.6)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.6)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.6)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.6)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.6)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.6)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.6)
+ babel-preset-jest@29.6.3(@babel/core@7.28.6):
+ dependencies:
+ '@babel/core': 7.28.6
babel-plugin-jest-hoist: 29.6.3
- babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.0)
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6)
balanced-match@1.0.2: {}
+ base32.js@0.1.0: {}
+ base64-js@1.5.1: {}
+ baseline-browser-mapping@2.9.14: {}
+ bignumber.js@9.3.1: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -1489,12 +1638,13 @@ snapshots:
braces@3.0.3:
dependencies:
fill-range: 7.1.1
- browserslist@4.25.1:
+ browserslist@4.28.1:
dependencies:
- caniuse-lite: 1.0.30001731
- electron-to-chromium: 1.5.194
- node-releases: 2.0.19
- update-browserslist-db: 1.1.3(browserslist@4.25.1)
+ baseline-browser-mapping: 2.9.14
+ caniuse-lite: 1.0.30001764
+ electron-to-chromium: 1.5.267
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
bs-logger@0.2.6:
dependencies:
fast-json-stable-stringify: 2.1.0
@@ -1502,18 +1652,28 @@ snapshots:
dependencies:
node-int64: 0.4.0
buffer-from@1.1.2: {}
- bufferutil@4.0.9:
+ buffer@6.0.3:
dependencies:
- node-gyp-build: 4.8.4
- optional: true
+ base64-js: 1.5.1
+ ieee754: 1.2.1
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
+ call-bind@1.0.8:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
callsites@3.1.0: {}
camelcase@5.3.1: {}
camelcase@6.3.0: {}
- caniuse-lite@1.0.30001731: {}
+ caniuse-lite@1.0.30001764: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -1527,7 +1687,7 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
co@4.6.0: {}
- collect-v8-coverage@1.0.2: {}
+ collect-v8-coverage@1.0.3: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -1537,13 +1697,13 @@ snapshots:
delayed-stream: 1.0.0
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
- create-jest@29.7.0(@types/node@24.2.0):
+ create-jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@24.2.0)
+ jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -1551,28 +1711,35 @@ snapshots:
- babel-plugin-macros
- supports-color
- ts-node
+ create-require@1.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
- debug@4.4.1:
+ debug@4.4.3:
dependencies:
ms: 2.1.3
- dedent@1.6.0: {}
+ dedent@1.7.1: {}
deepmerge@4.3.1: {}
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
delayed-stream@1.0.0: {}
detect-newline@3.1.0: {}
diff-sequences@29.6.3: {}
+ diff@4.0.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
- electron-to-chromium@1.5.194: {}
+ electron-to-chromium@1.5.267: {}
emittery@0.13.1: {}
emoji-regex@8.0.0: {}
- error-ex@1.3.2:
+ error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {}
@@ -1589,18 +1756,7 @@ snapshots:
escalade@3.2.0: {}
escape-string-regexp@2.0.0: {}
esprima@4.0.1: {}
- ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10):
- dependencies:
- '@adraffy/ens-normalize': 1.10.1
- '@noble/curves': 1.2.0
- '@noble/hashes': 1.3.2
- '@types/node': 22.7.5
- aes-js: 4.0.0-beta.5
- tslib: 2.7.0
- ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - bufferutil
- - utf-8-validate
+ eventsource@2.0.2: {}
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -1624,6 +1780,9 @@ snapshots:
fb-watchman@2.0.2:
dependencies:
bser: 2.1.1
+ feaxios@0.0.23:
+ dependencies:
+ is-retry-allowed: 3.0.0
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -1632,7 +1791,10 @@ snapshots:
locate-path: 5.0.0
path-exists: 4.0.0
follow-redirects@1.15.11: {}
- form-data@4.0.4:
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+ form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
@@ -1682,6 +1844,9 @@ snapshots:
optionalDependencies:
uglify-js: 3.19.3
has-flag@4.0.0: {}
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
@@ -1692,6 +1857,7 @@ snapshots:
html-escaper@2.0.2: {}
human-signals@2.1.0: {}
husky@9.1.7: {}
+ ieee754@1.2.1: {}
import-local@3.2.0:
dependencies:
pkg-dir: 4.2.0
@@ -1703,19 +1869,25 @@ snapshots:
wrappy: 1.0.2
inherits@2.0.4: {}
is-arrayish@0.2.1: {}
+ is-callable@1.2.7: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
is-fullwidth-code-point@3.0.0: {}
is-generator-fn@2.1.0: {}
is-number@7.0.0: {}
+ is-retry-allowed@3.0.0: {}
is-stream@2.0.1: {}
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.20
+ isarray@2.0.5: {}
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-instrument@5.2.1:
dependencies:
- '@babel/core': 7.28.0
- '@babel/parser': 7.28.0
+ '@babel/core': 7.28.6
+ '@babel/parser': 7.28.6
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
semver: 6.3.1
@@ -1723,11 +1895,11 @@ snapshots:
- supports-color
istanbul-lib-instrument@6.0.3:
dependencies:
- '@babel/core': 7.28.0
- '@babel/parser': 7.28.0
+ '@babel/core': 7.28.6
+ '@babel/parser': 7.28.6
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
- semver: 7.7.2
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
istanbul-lib-report@3.0.1:
@@ -1737,12 +1909,12 @@ snapshots:
supports-color: 7.2.0
istanbul-lib-source-maps@4.0.1:
dependencies:
- debug: 4.4.1
+ debug: 4.4.3
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
- istanbul-reports@3.1.7:
+ istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
@@ -1757,10 +1929,10 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
chalk: 4.1.2
co: 4.6.0
- dedent: 1.6.0
+ dedent: 1.7.1
is-generator-fn: 2.1.0
jest-each: 29.7.0
jest-matcher-utils: 29.7.0
@@ -1776,16 +1948,16 @@ snapshots:
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0(@types/node@24.2.0):
+ jest-cli@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@24.2.0)
+ create-jest: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@24.2.0)
+ jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -1794,12 +1966,12 @@ snapshots:
- babel-plugin-macros
- supports-color
- ts-node
- jest-config@29.7.0(@types/node@24.2.0):
+ jest-config@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)):
dependencies:
- '@babel/core': 7.28.0
+ '@babel/core': 7.28.6
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
- babel-jest: 29.7.0(@babel/core@7.28.0)
+ babel-jest: 29.7.0(@babel/core@7.28.6)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -1819,7 +1991,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
+ ts-node: 10.9.2(@types/node@24.10.9)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -1844,7 +2017,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
jest-mock: 29.7.0
jest-util: 29.7.0
jest-get-type@29.6.3: {}
@@ -1852,7 +2025,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -1875,7 +2048,7 @@ snapshots:
pretty-format: 29.7.0
jest-message-util@29.7.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.28.6
'@jest/types': 29.6.3
'@types/stack-utils': 2.0.3
chalk: 4.1.2
@@ -1887,7 +2060,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
jest-util: 29.7.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
optionalDependencies:
@@ -1907,7 +2080,7 @@ snapshots:
jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0)
jest-util: 29.7.0
jest-validate: 29.7.0
- resolve: 1.22.10
+ resolve: 1.22.11
resolve.exports: 2.0.3
slash: 3.0.0
jest-runner@29.7.0:
@@ -1917,7 +2090,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -1944,10 +2117,10 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
chalk: 4.1.2
cjs-module-lexer: 1.4.3
- collect-v8-coverage: 1.0.2
+ collect-v8-coverage: 1.0.3
glob: 7.2.3
graceful-fs: 4.2.11
jest-haste-map: 29.7.0
@@ -1963,15 +2136,15 @@ snapshots:
- supports-color
jest-snapshot@29.7.0:
dependencies:
- '@babel/core': 7.28.0
- '@babel/generator': 7.28.0
- '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
- '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0)
- '@babel/types': 7.28.2
+ '@babel/core': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6)
+ '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6)
+ '@babel/types': 7.28.6
'@jest/expect-utils': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.0)
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6)
chalk: 4.1.2
expect: 29.7.0
graceful-fs: 4.2.11
@@ -1982,13 +2155,13 @@ snapshots:
jest-util: 29.7.0
natural-compare: 1.4.0
pretty-format: 29.7.0
- semver: 7.7.2
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -2005,7 +2178,7 @@ snapshots:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -2013,23 +2186,23 @@ snapshots:
string-length: 4.0.2
jest-worker@29.7.0:
dependencies:
- '@types/node': 24.2.0
+ '@types/node': 24.10.9
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@24.2.0):
+ jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@24.2.0)
+ jest-cli: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
js-tokens@4.0.0: {}
- js-yaml@3.14.1:
+ js-yaml@3.14.2:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
@@ -2048,7 +2221,7 @@ snapshots:
yallist: 3.1.1
make-dir@4.0.0:
dependencies:
- semver: 7.7.2
+ semver: 7.7.3
make-error@1.3.6: {}
makeerror@1.0.12:
dependencies:
@@ -2071,10 +2244,8 @@ snapshots:
ms@2.1.3: {}
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
- node-gyp-build@4.8.4:
- optional: true
node-int64@0.4.0: {}
- node-releases@2.0.19: {}
+ node-releases@2.0.27: {}
normalize-path@3.0.0: {}
npm-run-path@4.0.1:
dependencies:
@@ -2097,8 +2268,8 @@ snapshots:
p-try@2.2.0: {}
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
- error-ex: 1.3.2
+ '@babel/code-frame': 7.28.6
+ error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
path-exists@4.0.0: {}
@@ -2111,6 +2282,7 @@ snapshots:
pkg-dir@4.2.0:
dependencies:
find-up: 4.1.0
+ possible-typed-array-names@1.1.0: {}
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3
@@ -2122,6 +2294,9 @@ snapshots:
sisteransi: 1.0.5
proxy-from-env@1.1.0: {}
pure-rand@6.1.0: {}
+ randombytes@2.1.0:
+ dependencies:
+ safe-buffer: 5.2.1
react-is@18.3.1: {}
require-directory@2.1.1: {}
resolve-cwd@3.0.0:
@@ -2129,13 +2304,27 @@ snapshots:
resolve-from: 5.0.0
resolve-from@5.0.0: {}
resolve.exports@2.0.3: {}
- resolve@1.22.10:
+ resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
+ safe-buffer@5.2.1: {}
semver@6.3.1: {}
- semver@7.7.2: {}
+ semver@7.7.3: {}
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ sha.js@2.4.12:
+ dependencies:
+ inherits: 2.0.4
+ safe-buffer: 5.2.1
+ to-buffer: 1.2.2
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2180,55 +2369,87 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.2
tmpl@1.0.5: {}
+ to-buffer@1.2.2:
+ dependencies:
+ isarray: 2.0.5
+ safe-buffer: 5.2.1
+ typed-array-buffer: 1.0.3
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
- ? ts-jest@29.4.1(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.2.0))(typescript@5.9.2)
+ toml@3.0.0: {}
+ ? ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)))(typescript@5.9.3)
: dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@24.2.0)
+ jest: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
- semver: 7.7.2
+ semver: 7.7.3
type-fest: 4.41.0
- typescript: 5.9.2
+ typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
- '@babel/core': 7.28.0
+ '@babel/core': 7.28.6
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- babel-jest: 29.7.0(@babel/core@7.28.0)
+ babel-jest: 29.7.0(@babel/core@7.28.6)
jest-util: 29.7.0
- tslib@2.7.0: {}
+ ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 24.10.9
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.9.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
type-detect@4.0.8: {}
type-fest@0.21.3: {}
type-fest@4.41.0: {}
- typescript@5.9.2: {}
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+ typescript@5.9.3: {}
uglify-js@3.19.3:
optional: true
- undici-types@6.19.8: {}
- undici-types@7.10.0: {}
- update-browserslist-db@1.1.3(browserslist@4.25.1):
+ undici-types@7.16.0: {}
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
- browserslist: 4.25.1
+ browserslist: 4.28.1
escalade: 3.2.0
picocolors: 1.1.1
- utf-8-validate@5.0.10:
- dependencies:
- node-gyp-build: 4.8.4
- optional: true
- uuid@11.1.0: {}
+ urijs@1.19.11: {}
+ v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
dependencies:
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/trace-mapping': 0.3.31
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
walker@1.0.8:
dependencies:
makeerror: 1.0.12
+ which-typed-array@1.1.20:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -2243,10 +2464,6 @@ snapshots:
dependencies:
imurmurhash: 0.1.4
signal-exit: 3.0.7
- ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10):
- optionalDependencies:
- bufferutil: 4.0.9
- utf-8-validate: 5.0.10
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@21.1.1: {}
@@ -2259,4 +2476,5 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
+ yn@3.1.1: {}
yocto-queue@0.1.0: {}
diff --git a/examples/channels-plugin-example/config/config.json b/examples/channels-plugin-example/config/config.json
index 1b6df0b37..d87f87cee 100644
--- a/examples/channels-plugin-example/config/config.json
+++ b/examples/channels-plugin-example/config/config.json
@@ -8,7 +8,8 @@
"network_type": "stellar",
"signer_id": "channels-fund-signer",
"policies": {
- "concurrent_transactions": true
+ "concurrent_transactions": true,
+ "fee_payment_strategy": "relayer"
}
},
{
@@ -17,7 +18,10 @@
"network": "testnet",
"paused": false,
"network_type": "stellar",
- "signer_id": "channel-001-signer"
+ "signer_id": "channel-001-signer",
+ "policies": {
+ "fee_payment_strategy": "relayer"
+ }
},
{
"id": "channel-002",
@@ -25,7 +29,10 @@
"network": "testnet",
"paused": false,
"network_type": "stellar",
- "signer_id": "channel-002-signer"
+ "signer_id": "channel-002-signer",
+ "policies": {
+ "fee_payment_strategy": "relayer"
+ }
}
],
"notifications": [],
diff --git a/openapi.json b/openapi.json
index 393e09e18..3cbee8653 100644
--- a/openapi.json
+++ b/openapi.json
@@ -956,6 +956,269 @@
]
}
},
+ "/api/v1/plugins/{plugin_id}": {
+ "get": {
+ "tags": [
+ "Plugins"
+ ],
+ "summary": "Get plugin by ID",
+ "operationId": "getPlugin",
+ "parameters": [
+ {
+ "name": "plugin_id",
+ "in": "path",
+ "description": "The unique identifier of the plugin",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Plugin retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_PluginModel"
+ },
+ "example": {
+ "data": {
+ "allow_get_invocation": false,
+ "config": {
+ "featureFlag": true
+ },
+ "emit_logs": false,
+ "emit_traces": false,
+ "forward_logs": false,
+ "id": "my-plugin",
+ "path": "plugins/my-plugin.ts",
+ "raw_response": false,
+ "timeout": 30
+ },
+ "error": null,
+ "success": true
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Unauthorized",
+ "success": false
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Plugin not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Plugin with id my-plugin not found",
+ "success": false
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Too Many Requests",
+ "success": false
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Internal Server Error",
+ "success": false
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_auth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Plugins"
+ ],
+ "summary": "Update plugin configuration",
+ "description": "Updates mutable plugin fields such as timeout, emit_logs, emit_traces,\nraw_response, allow_get_invocation, config, and forward_logs.\nThe plugin id and path cannot be changed after creation.\n\nAll fields are optional - only the provided fields will be updated.\nTo clear the `config` field, pass `\"config\": null`.",
+ "operationId": "updatePlugin",
+ "parameters": [
+ {
+ "name": "plugin_id",
+ "in": "path",
+ "description": "The unique identifier of the plugin",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Plugin configuration update. All fields are optional.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdatePluginRequest"
+ },
+ "example": {
+ "config": {
+ "apiKey": "xyz123",
+ "featureFlag": true
+ },
+ "emit_logs": true,
+ "forward_logs": true,
+ "timeout": 60
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Plugin updated successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_PluginModel"
+ },
+ "example": {
+ "data": {
+ "allow_get_invocation": false,
+ "config": {
+ "apiKey": "xyz123",
+ "featureFlag": true
+ },
+ "emit_logs": true,
+ "emit_traces": false,
+ "forward_logs": true,
+ "id": "my-plugin",
+ "path": "plugins/my-plugin.ts",
+ "raw_response": false,
+ "timeout": 60
+ },
+ "error": null,
+ "success": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request (invalid timeout or other validation error)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Timeout must be greater than 0",
+ "success": false
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Unauthorized",
+ "success": false
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Plugin not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Plugin with id my-plugin not found",
+ "success": false
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Too Many Requests",
+ "success": false
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse_String"
+ },
+ "example": {
+ "data": null,
+ "error": "Internal Server Error",
+ "success": false
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_auth": []
+ }
+ ]
+ }
+ },
"/api/v1/plugins/{plugin_id}/call": {
"get": {
"tags": [
@@ -4631,6 +4894,81 @@
}
}
},
+ "ApiResponse_PluginModel": {
+ "type": "object",
+ "required": [
+ "success"
+ ],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "id",
+ "path",
+ "timeout"
+ ],
+ "properties": {
+ "allow_get_invocation": {
+ "type": "boolean",
+ "description": "Whether to allow GET requests to invoke plugin logic"
+ },
+ "config": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "description": "User-defined configuration accessible to the plugin (must be a JSON object)",
+ "additionalProperties": {},
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "emit_logs": {
+ "type": "boolean",
+ "description": "Whether to include logs in the HTTP response"
+ },
+ "emit_traces": {
+ "type": "boolean",
+ "description": "Whether to include traces in the HTTP response"
+ },
+ "forward_logs": {
+ "type": "boolean",
+ "description": "Whether to forward plugin logs into the relayer's tracing output"
+ },
+ "id": {
+ "type": "string",
+ "description": "Plugin ID"
+ },
+ "path": {
+ "type": "string",
+ "description": "Plugin path"
+ },
+ "raw_response": {
+ "type": "boolean",
+ "description": "Whether to return raw plugin response without ApiResponse wrapper"
+ },
+ "timeout": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Plugin timeout",
+ "minimum": 0
+ }
+ }
+ },
+ "error": {
+ "type": "string"
+ },
+ "metadata": {
+ "$ref": "#/components/schemas/PluginMetadata"
+ },
+ "pagination": {
+ "$ref": "#/components/schemas/PaginationMeta"
+ },
+ "success": {
+ "type": "boolean"
+ }
+ }
+ },
"ApiResponse_RelayerResponse": {
"type": "object",
"required": [
@@ -9407,6 +9745,68 @@
},
"additionalProperties": false
},
+ "UpdatePluginRequest": {
+ "type": "object",
+ "description": "Request model for updating an existing plugin.\nAll fields are optional to allow partial updates.\nNote: `id` and `path` are not updateable after creation.",
+ "properties": {
+ "allow_get_invocation": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to allow GET requests to invoke plugin logic"
+ },
+ "config": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "description": "User-defined configuration accessible to the plugin (must be a JSON object)\nUse `null` to clear the config",
+ "additionalProperties": {},
+ "propertyNames": {
+ "type": "string"
+ }
+ },
+ "emit_logs": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to include logs in the HTTP response"
+ },
+ "emit_traces": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to include traces in the HTTP response"
+ },
+ "forward_logs": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to forward plugin logs into the relayer's tracing output"
+ },
+ "raw_response": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Whether to return raw plugin response without ApiResponse wrapper"
+ },
+ "timeout": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "int64",
+ "description": "Plugin timeout in seconds",
+ "minimum": 0
+ }
+ },
+ "additionalProperties": false
+ },
"UpdateRelayerRequest": {
"type": "object",
"properties": {
diff --git a/plugins/ARCHITECTURE.md b/plugins/ARCHITECTURE.md
new file mode 100644
index 000000000..326e6b6cf
--- /dev/null
+++ b/plugins/ARCHITECTURE.md
@@ -0,0 +1,289 @@
+# Plugin System Architecture
+
+Developer guide for understanding and modifying the plugin execution system.
+
+## High-Level Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Rust Relayer │
+│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
+│ │ HTTP API │───▶│ PluginRunner │───▶│ PoolManager │ │
+│ │ /plugins/* │ │ │ │ - CircuitBreaker │ │
+│ └─────────────┘ └──────────────┘ │ - ConnectionPool │ │
+│ │ - RequestQueue │ │
+│ └───────────┬──────────────┘ │
+│ │ │
+│ Unix Socket (JSON-line protocol) │
+│ │ │
+└──────────────────────────────────────────────────────┼───────────────────┘
+ │
+┌──────────────────────────────────────────────────────┼───────────────────┐
+│ Node.js Pool Server │ │
+│ ┌───────────▼──────────────┐ │
+│ ┌──────────────────────┐ │ pool-server.ts │ │
+│ │ Piscina Worker Pool │◀──────────────│ - Request Router │ │
+│ │ ┌─────────────────┐ │ │ - Memory Pressure Mon │ │
+│ │ │ Worker Thread 1 │ │ │ - Cache Management │ │
+│ │ │ (sandbox-exec) │ │ └──────────────────────────┘ │
+│ │ ├─────────────────┤ │ │
+│ │ │ Worker Thread 2 │ │ ┌──────────────────────────┐ │
+│ │ │ (sandbox-exec) │ │ │ SharedSocketService │ │
+│ │ ├─────────────────┤ │◀─────────────▶│ (Rust ↔ Plugin comms) │ │
+│ │ │ Worker Thread N │ │ └──────────────────────────┘ │
+│ │ └─────────────────┘ │ │
+│ └──────────────────────┘ │
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+## Component Overview
+
+### Rust Side (`src/services/plugins/`)
+
+| Module | Purpose |
+|--------|---------|
+| `config.rs` | Centralized configuration with auto-derivation from `PLUGIN_MAX_CONCURRENCY` |
+| `runner.rs` | Entry point - routes requests to pool executor, handles precompilation |
+| `pool_executor.rs` | Manages Node.js process lifecycle, connection pooling, circuit breaker |
+| `health.rs` | Circuit breaker, health status, dead server detection |
+| `protocol.rs` | JSON-line message types (PoolRequest, PoolResponse) |
+| `connection.rs` | Lock-free connection pool with semaphore-based concurrency |
+| `shared_socket.rs` | Per-request Unix socket for plugin ↔ Rust API communication |
+| `relayer_api.rs` | Plugin API implementation (sendTransaction, signMessage, etc.) |
+
+### Node.js Side (`plugins/lib/`)
+
+| Module | Purpose |
+|--------|---------|
+| `pool-server.ts` | Main server - accepts connections, routes to workers, manages memory |
+| `worker-pool.ts` | Piscina wrapper with dynamic scaling and cache management |
+| `pool-executor.ts` | Plugin execution |
+| `compiler.ts` | TypeScript/JavaScript compilation with esbuild |
+| `plugin.ts` | Plugin SDK (PluginContext, PluginAPI) |
+| `kv.ts` | Redis-backed key-value store for plugins |
+
+## Communication Protocol
+
+### Pool Protocol (Rust ↔ pool-server.ts)
+
+Unix socket at `/tmp/relayer-plugin-pool-{uuid}.sock` using newline-delimited JSON.
+
+**Request Types:**
+```typescript
+// Execute a plugin
+{ type: "execute", taskId, pluginId, compiledCode?, pluginPath?, params, socketPath, timeout? }
+
+// Precompile TypeScript
+{ type: "precompile", taskId, pluginId, pluginPath?, sourceCode? }
+
+// Cache compiled code
+{ type: "cache", taskId, pluginId, compiledCode }
+
+// Invalidate cache
+{ type: "invalidate", taskId, pluginId }
+
+// Health check
+{ type: "health", taskId }
+
+// Graceful shutdown
+{ type: "shutdown", taskId }
+```
+
+**Response:**
+```typescript
+{ taskId, success: boolean, result?, error?: { message, code?, status?, details? }, logs? }
+```
+
+### Shared Socket Protocol (Plugin ↔ Rust)
+
+Per-request socket at `/tmp/relayer-shared-{uuid}.sock` for plugin API calls.
+
+```typescript
+// Plugin sends:
+{ type: "sendTransaction", requestId, relayerId, transaction }
+{ type: "getRelayerBalance", requestId, relayerId }
+// ... other API methods
+
+// Rust responds:
+{ requestId, success: boolean, data?, error? }
+```
+
+## Request Flow
+
+```
+1. HTTP POST /api/v1/plugins/{id}/call
+ │
+2. PluginRunner.run_plugin()
+ │
+ ├─ Check if compiled code cached
+ │ └─ If not: precompile via pool
+ │
+3. PoolManager.execute_plugin()
+ │
+ ├─ Circuit breaker check (reject if open)
+ │
+ ├─ Try acquire connection permit (fast path)
+ │ └─ If unavailable: queue request (slow path)
+ │
+4. Send Execute request to pool-server
+ │
+5. pool-server routes to Piscina worker
+ │
+6. pool-executor.ts runs plugin in VM
+ │
+ ├─ Plugin calls api.useRelayer().sendTransaction()
+ │ └─ Communicates via shared socket to Rust
+ │
+7. Response flows back through chain
+ │
+8. HTTP 200 with { success, data, metadata }
+```
+
+## Environment Variables
+
+> **Source of truth**: `src/constants/plugins.rs` defines all default values.
+> Auto-derivation logic is in `src/services/plugins/config.rs`.
+
+### Primary Scaling Knob
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PLUGIN_MAX_CONCURRENCY` | 2048 | **Main knob** - drives auto-derivation of all other values |
+
+### Auto-Derived (override only if needed)
+
+| Variable | Default Formula | Description |
+|----------|-----------------|-------------|
+| `PLUGIN_POOL_MAX_CONNECTIONS` | = max_concurrency | Rust connection pool size |
+| `PLUGIN_POOL_MAX_QUEUE_SIZE` | = max_concurrency * 2 | Request queue capacity |
+| `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` | = max_concurrency * 1.5 | Shared socket connections |
+| `PLUGIN_POOL_MIN_THREADS` | max(2, cpu_count/2) | Node.js minimum workers |
+| `PLUGIN_POOL_MAX_THREADS` | memory-aware, max 32 | Node.js maximum workers |
+| `PLUGIN_POOL_CONCURRENT_TASKS` | (concurrency/threads)*1.2, max 250 | Tasks per worker |
+| `PLUGIN_WORKER_HEAP_MB` | 512 + (concurrent_tasks * 5) | Per-worker heap size |
+
+### Fine-Tuning
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PLUGIN_POOL_WORKERS` | 0 (auto) | Rust queue worker threads |
+| `PLUGIN_POOL_CONNECT_RETRIES` | 15 | Connection retry attempts |
+| `PLUGIN_POOL_REQUEST_TIMEOUT_SECS` | 30 | Per-request timeout |
+| `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` | 500 | Queue wait timeout (auto-scales to 1000) |
+| `PLUGIN_POOL_IDLE_TIMEOUT` | 60000 | Worker idle timeout (ms) |
+| `PLUGIN_POOL_SOCKET_BACKLOG` | max(concurrency, 2048) | Socket backlog size |
+| `PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS` | 5 | Health check frequency |
+| `PLUGIN_SOCKET_IDLE_TIMEOUT_SECS` | 60 | Shared socket idle timeout |
+| `PLUGIN_SOCKET_READ_TIMEOUT_SECS` | 30 | Shared socket read timeout |
+
+## Health & Recovery
+
+### Circuit Breaker States
+
+```
+CLOSED ──(>50% failure rate)──▶ OPEN
+ ▲ │
+ │ (5s backoff)
+ │ ▼
+ └──(10 consecutive OK)──── HALF_OPEN
+```
+
+- **Closed**: All requests allowed
+- **Open**: Most requests rejected, pool server restart triggered
+- **Half-Open**: 10% of requests allowed to probe recovery
+
+### Dead Server Detection
+
+Errors that trigger automatic restart:
+- `eof while parsing` - Connection closed mid-message
+- `broken pipe` - Write to closed socket
+- `connection refused` - Server not listening
+- `connection reset` - Server forcefully closed
+- `socket file missing` - Unix socket deleted
+
+### Memory Pressure Handling
+
+pool-server.ts monitors heap usage and triggers:
+1. **75% heap**: Force GC, evict old cache entries
+2. **85% heap**: Aggressive cache eviction, warning logs
+3. **95% heap**: Emergency measures, reject new requests
+
+## Performance Tuning
+
+### Low Concurrency (<100 plugins/sec)
+```bash
+export PLUGIN_MAX_CONCURRENCY=100
+# Everything auto-derives appropriately
+```
+
+### Medium Concurrency (100-1000 plugins/sec)
+```bash
+export PLUGIN_MAX_CONCURRENCY=500
+# Consider increasing if seeing queue full errors:
+# export PLUGIN_POOL_MAX_QUEUE_SIZE=2000
+```
+
+### High Concurrency (1000+ plugins/sec)
+```bash
+export PLUGIN_MAX_CONCURRENCY=5000
+# On 32GB+ systems, can increase threads:
+# export PLUGIN_POOL_MAX_THREADS=16
+# export PLUGIN_POOL_CONCURRENT_TASKS=200
+```
+
+### Debugging
+
+Enable debug logging:
+```bash
+RUST_LOG=openzeppelin_relayer::services::plugins=debug cargo run
+```
+
+Check health endpoint:
+```bash
+curl http://localhost:8080/api/v1/health/plugins
+```
+
+## Module Dependency Graph
+
+```
+config.rs
+ │
+ ▼
+health.rs ◀─────────────────────┐
+ │ │
+ ▼ │
+protocol.rs │
+ │ │
+ ▼ │
+connection.rs │
+ │ │
+ ▼ │
+pool_executor.rs ───────────────┘
+ │
+ ▼
+runner.rs ◀──── shared_socket.rs ◀──── relayer_api.rs
+```
+
+## Testing
+
+```bash
+# Rust unit tests
+cargo test --package openzeppelin-relayer plugins
+
+# TypeScript tests
+cd plugins && pnpm test
+
+# Integration test with load
+# (requires k6: https://k6.io)
+k6 run tests/load/plugins.js
+```
+
+## Common Issues
+
+| Symptom | Likely Cause | Solution |
+|---------|--------------|----------|
+| "Queue full" errors | Max concurrency too low | Increase `PLUGIN_MAX_CONCURRENCY` |
+| OOM in Node.js | Too many threads/tasks | Reduce `PLUGIN_POOL_MAX_THREADS` |
+| Slow response times | GC pressure | Check health endpoint, reduce concurrent tasks |
+| "Circuit breaker open" | Pool server crashed | Check logs, will auto-recover |
+| Connection refused | Pool server not started | Check `ensure_started()` is called |
diff --git a/plugins/examples/example-rpc.ts b/plugins/examples/example-rpc.ts
index dcc69ae29..79ac49199 100644
--- a/plugins/examples/example-rpc.ts
+++ b/plugins/examples/example-rpc.ts
@@ -22,7 +22,7 @@ export async function handler(context: PluginContext): Promise {
+ const content = await fs.promises.readFile(filePath);
+ return crypto.createHash('sha256').update(content).digest('hex');
+}
+
+/**
+ * Safe file deletion - handles EBUSY, ENOENT gracefully
+ */
+async function safeUnlink(filePath: string): Promise {
+ try {
+ await fs.promises.unlink(filePath);
+ return true;
+ } catch (err) {
+ const error = err as NodeJS.ErrnoException;
+ // Ignore common non-critical errors
+ if (error.code === 'ENOENT') return false; // File doesn't exist
+ if (error.code === 'EBUSY') {
+ console.warn(` ⚠ File busy, skipping cleanup: ${filePath}`);
+ return false;
+ }
+ if (error.code === 'EPERM') {
+ console.warn(` ⚠ Permission denied, skipping cleanup: ${filePath}`);
+ return false;
+ }
+ // Re-throw unexpected errors
+ throw err;
+ }
+}
+
+/**
+ * Clean up partial output files on failure
+ */
+async function cleanup(tempPath?: string): Promise {
+ console.log('Cleaning up...');
+ if (tempPath) await safeUnlink(tempPath);
+ await safeUnlink(OUTPUT_PATH);
+ await safeUnlink(HASH_PATH);
+}
+
+/**
+ * Validate input file exists and is readable
+ */
+async function validateInput(): Promise {
+ try {
+ await fs.promises.access(INPUT_PATH, fs.constants.R_OK);
+ } catch (err) {
+ const error = err as NodeJS.ErrnoException;
+ if (error.code === 'ENOENT') {
+ throw new Error(`Input file not found: ${INPUT_PATH}`);
+ }
+ throw new Error(`Input file is not readable: ${INPUT_PATH}`);
+ }
+
+ const stats = await fs.promises.stat(INPUT_PATH);
+ if (!stats.isFile()) {
+ throw new Error(`Input path is not a file: ${INPUT_PATH}`);
+ }
+
+ if (stats.size === 0) {
+ throw new Error(`Input file is empty: ${INPUT_PATH}`);
+ }
+}
+
+/**
+ * Check if build can be skipped (input unchanged)
+ */
+async function checkBuildCache(inputHash: string, force: boolean): Promise {
+ if (force) {
+ console.log(' → Force flag set, skipping cache check');
+ return false;
+ }
+
+ // Check if output exists
+ try {
+ await fs.promises.access(OUTPUT_PATH);
+ } catch {
+ console.log(' → Output file missing, rebuild required');
+ return false;
+ }
+
+ // Check if hash file exists and compare
+ try {
+ const cachedHash = (await fs.promises.readFile(CACHE_HASH_PATH, 'utf-8')).trim();
+ if (cachedHash === inputHash) {
+ return true; // Cache hit - no rebuild needed
+ }
+ console.log(' → Input changed, rebuild required');
+ } catch {
+ console.log(' → Cache hash missing or read failed, rebuild required');
+ }
+
+ return false;
+}
+
+/**
+ * Verify output file has valid JavaScript syntax (without executing)
+ */
+async function verifySyntax(filePath: string): Promise {
+ const content = await fs.promises.readFile(filePath, 'utf-8');
+
+ try {
+ // Use vm.Script to parse without executing
+ // This catches syntax errors without side effects
+ new vm.Script(content, { filename: filePath });
+ } catch (err) {
+ const error = err as Error;
+ throw new Error(`Output has invalid JavaScript syntax: ${error.message}`);
+ }
+}
+
+/**
+ * Verify output file is valid
+ */
+async function verifyOutput(filePath: string): Promise {
+ try {
+ await fs.promises.access(filePath);
+ } catch {
+ throw new Error(`Output file was not created: ${filePath}`);
+ }
+
+ const stats = await fs.promises.stat(filePath);
+ if (stats.size === 0) {
+ throw new Error(`Output file is empty: ${filePath}`);
+ }
+
+ // Syntax check without execution (no side effects)
+ await verifySyntax(filePath);
+}
+
+/**
+ * Atomic file write: write to temp, then rename
+ */
+async function atomicWriteFile(targetPath: string, content: string): Promise {
+ const tempPath = path.join(
+ os.tmpdir(),
+ `build-executor-${crypto.randomUUID()}.tmp`
+ );
+
+ await fs.promises.writeFile(tempPath, content, 'utf-8');
+ await fs.promises.rename(tempPath, targetPath);
+
+ return tempPath;
+}
+
+/**
+ * Write hash to .sha256 file for runtime verification
+ */
+async function writeHashFile(hash: string): Promise {
+ const content = `${hash} pool-executor.js\n`;
+ await atomicWriteFile(HASH_PATH, content);
+}
+
+/**
+ * Save input hash for build caching
+ */
+async function saveBuildCache(inputHash: string): Promise {
+ await atomicWriteFile(CACHE_HASH_PATH, inputHash);
+}
+
+/**
+ * Generate build metadata banner
+ */
+function generateBanner(inputHash: string): string {
+ const now = new Date().toISOString();
+ const nodeVersion = process.version;
+ return `/**
+ * Auto-generated by build-executor.ts
+ * Build time: ${now}
+ * Node version: ${nodeVersion}
+ * Source: pool-executor.ts
+ * Source hash: ${inputHash.substring(0, 16)}
+ * DO NOT EDIT - Regenerate with: npx ts-node build-executor.ts
+ */`;
+}
+
+async function build(): Promise {
+ const forceRebuild = process.argv.includes('--force');
+ let tempOutputPath: string | undefined;
+
+ console.log('=== Building pool-executor ===');
+ console.log(`Input: ${INPUT_PATH}`);
+ console.log(`Output: ${OUTPUT_PATH}`);
+
+ // Step 1: Validate dependencies
+ console.log('\n[1/7] Checking dependencies...');
+ console.log(' ✓ esbuild available');
+
+ // Step 2: Validate input
+ console.log('\n[2/7] Validating input file...');
+ await validateInput();
+ const inputHash = await calculateHash(INPUT_PATH);
+ console.log(` ✓ Input valid (SHA256: ${inputHash.substring(0, 16)}...)`);
+
+ // Step 3: Check build cache
+ console.log('\n[3/7] Checking build cache...');
+ if (await checkBuildCache(inputHash, forceRebuild)) {
+ console.log(' ✓ Build cache valid - skipping rebuild');
+ console.log('\n=== Build skipped (up to date) ===');
+ return;
+ }
+
+ // Step 4: Build to temp file (atomic write preparation)
+ console.log('\n[4/7] Compiling TypeScript...');
+ tempOutputPath = path.join(os.tmpdir(), `pool-executor-${crypto.randomUUID()}.js`);
+
+ const result = await esbuild.build({
+ entryPoints: [INPUT_PATH],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ sourcemap: false,
+ write: true,
+ outfile: tempOutputPath,
+ loader: { '.ts': 'ts' },
+ external: ['node:*'],
+ banner: {
+ js: generateBanner(inputHash),
+ },
+ });
+
+ // Handle errors
+ if (result.errors.length > 0) {
+ console.error('\n❌ Build errors:');
+ for (const error of result.errors) {
+ console.error(` - ${error.text}`);
+ if (error.location) {
+ console.error(` at ${error.location.file}:${error.location.line}:${error.location.column}`);
+ }
+ }
+ await cleanup(tempOutputPath);
+ process.exit(1);
+ }
+
+ // Handle warnings
+ if (result.warnings.length > 0) {
+ console.log('\n⚠️ Build warnings:');
+ for (const warning of result.warnings) {
+ console.log(` - ${warning.text}`);
+ if (warning.location) {
+ console.log(` at ${warning.location.file}:${warning.location.line}:${warning.location.column}`);
+ }
+ }
+ }
+
+ console.log(' ✓ Compilation successful');
+
+ // Step 5: Verify output (syntax check, no execution)
+ console.log('\n[5/7] Verifying output syntax...');
+ await verifyOutput(tempOutputPath);
+ console.log(' ✓ Output has valid JavaScript syntax');
+
+ // Step 6: Atomic move to final location
+ console.log('\n[6/7] Finalizing output...');
+ await fs.promises.rename(tempOutputPath, OUTPUT_PATH);
+ tempOutputPath = undefined; // Clear so cleanup doesn't try to delete
+ console.log(' ✓ Output written atomically');
+
+ // Step 7: Write hash files and cache
+ console.log('\n[7/7] Writing integrity hash...');
+ const outputHash = await calculateHash(OUTPUT_PATH);
+ await writeHashFile(outputHash);
+ await saveBuildCache(inputHash);
+ console.log(` ✓ SHA256: ${outputHash}`);
+ console.log(` ✓ Hash saved to: ${HASH_PATH}`);
+
+ // Print summary
+ const stats = await fs.promises.stat(OUTPUT_PATH);
+ console.log('\n=== Build complete! ===');
+ console.log('─'.repeat(50));
+ console.log(` Output: ${OUTPUT_PATH}`);
+ console.log(` Hash file: ${HASH_PATH}`);
+ console.log(` Size: ${(stats.size / 1024).toFixed(1)} KB`);
+ console.log(` Input hash: ${inputHash.substring(0, 16)}...`);
+ console.log(` Output hash: ${outputHash.substring(0, 16)}...`);
+ console.log('─'.repeat(50));
+}
+
+// Run build with proper error handling
+build().catch(async (err) => {
+ const error = err as Error;
+ console.error(`\n❌ Build failed: ${error.message}`);
+ if (error.stack) {
+ console.error('\nStack trace:');
+ console.error(error.stack.split('\n').slice(1, 5).join('\n'));
+ }
+ await cleanup();
+ process.exit(1);
+});
diff --git a/plugins/lib/compiler.ts b/plugins/lib/compiler.ts
new file mode 100644
index 000000000..570cadab8
--- /dev/null
+++ b/plugins/lib/compiler.ts
@@ -0,0 +1,572 @@
+/**
+ * Plugin Compiler Module
+ *
+ * Uses esbuild for fast TypeScript → JavaScript compilation with bundling.
+ * Dependencies are bundled into the output for self-contained plugins.
+ *
+ * Security:
+ * - Input size limits to prevent memory exhaustion
+ * - Path traversal protection
+ * - Compilation timeout
+ * - Sanitized error messages
+ */
+
+import * as esbuild from 'esbuild';
+import type { Message } from 'esbuild';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+/** Maximum source file size (5MB) */
+const MAX_SOURCE_SIZE = 5 * 1024 * 1024;
+
+/** Maximum compiled code size (10MB) */
+const MAX_COMPILED_SIZE = 10 * 1024 * 1024;
+
+/** Default compilation timeout (30 seconds) */
+const DEFAULT_COMPILE_TIMEOUT_MS = 30000;
+
+/** Maximum concurrent compilations in batch mode */
+const MAX_CONCURRENT_COMPILATIONS = 10;
+
+/** Default base directory for plugins */
+const DEFAULT_PLUGIN_BASE_DIR = path.resolve(process.cwd(), 'plugins');
+
+/**
+ * Result of compiling a plugin
+ */
+export interface CompilationResult {
+ /** The compiled JavaScript code */
+ code: string;
+ /** Source map (optional, for debugging) */
+ sourceMap?: string;
+ /** Compilation warnings */
+ warnings: string[];
+}
+
+/**
+ * Options for plugin compilation
+ */
+export interface CompilerOptions {
+ /** Target ECMAScript version */
+ target?: 'es2020' | 'es2021' | 'es2022' | 'esnext';
+ /** Whether to generate source maps ('inline' or 'external') */
+ sourcemap?: boolean | 'inline' | 'external';
+ /** Whether to minify the output */
+ minify?: boolean;
+ /** Compilation timeout in milliseconds (default: 30000) */
+ timeout?: number;
+ /** Base directory for security checks (plugins must be within this directory) */
+ baseDir?: string;
+ /** Directory to resolve node_modules from (default: process.cwd()) */
+ resolveDir?: string;
+}
+
+/**
+ * Error codes for compilation failures
+ */
+export type CompilationErrorCode =
+ | 'FILE_NOT_FOUND'
+ | 'FILE_NOT_READABLE'
+ | 'SIZE_LIMIT'
+ | 'COMPILE_ERROR'
+ | 'TIMEOUT'
+ | 'PATH_TRAVERSAL'
+ | 'UNKNOWN';
+
+/**
+ * Error information from batch compilation
+ */
+export interface CompilationError {
+ /** Path to the plugin that failed */
+ pluginPath: string;
+ /** Error message (sanitized) */
+ message: string;
+ /** Error code for categorization */
+ code: CompilationErrorCode;
+}
+
+/**
+ * Result of batch compilation
+ */
+export interface BatchCompilationResult {
+ /** Successfully compiled plugins */
+ results: Map;
+ /** Compilation errors */
+ errors: CompilationError[];
+}
+
+const DEFAULT_OPTIONS: Required> & {
+ timeout: number;
+} = {
+ target: 'es2022',
+ sourcemap: false,
+ minify: false,
+ timeout: DEFAULT_COMPILE_TIMEOUT_MS,
+};
+
+/**
+ * Valid error codes for type checking
+ */
+const VALID_ERROR_CODES: readonly CompilationErrorCode[] = [
+ 'FILE_NOT_FOUND',
+ 'FILE_NOT_READABLE',
+ 'SIZE_LIMIT',
+ 'COMPILE_ERROR',
+ 'TIMEOUT',
+ 'PATH_TRAVERSAL',
+ 'UNKNOWN',
+];
+
+/**
+ * Normalize an error code to a valid CompilationErrorCode
+ */
+function normalizeErrorCode(code: unknown): CompilationErrorCode {
+ if (typeof code === 'string' && VALID_ERROR_CODES.includes(code as CompilationErrorCode)) {
+ return code as CompilationErrorCode;
+ }
+ return 'UNKNOWN';
+}
+
+/**
+ * Sanitize file path for safe error messages.
+ * Replaces absolute paths with relative alternatives for cleaner, portable error messages.
+ *
+ * @param filePath - The file path to sanitize
+ * @returns Sanitized path safe for error messages
+ */
+function sanitizePath(filePath: string): string {
+ // Replace current working directory with '.' for relative paths
+ const cwd = process.cwd();
+ if (filePath.startsWith(cwd)) {
+ return '.' + filePath.slice(cwd.length);
+ }
+
+ // If path is outside CWD, return as-is (already absolute or relative)
+ return filePath;
+}
+
+/**
+ * Validate that a path doesn't contain traversal attacks.
+ *
+ * @param pluginPath - Path to validate
+ * @param basePath - Optional base path that the plugin must be within
+ * @throws Error if path contains traversal sequences
+ */
+function validatePathSecurity(pluginPath: string, basePath?: string): void {
+ // Normalize the path first
+ const normalized = path.normalize(pluginPath);
+
+ // Check if the NORMALIZED path still contains '..' (actual traversal)
+ const parts = normalized.split(path.sep);
+ if (parts.includes('..')) {
+ throw Object.assign(new Error('Path traversal not allowed'), {
+ code: 'PATH_TRAVERSAL',
+ });
+ }
+
+ // If base path specified, ensure plugin is within it
+ if (basePath) {
+ const absolutePlugin = path.isAbsolute(normalized)
+ ? normalized
+ : path.resolve(basePath, normalized);
+ const absoluteBase = path.resolve(basePath);
+
+ // Ensure plugin path starts with base path (with separator to avoid partial matches)
+ if (!absolutePlugin.startsWith(absoluteBase + path.sep) && absolutePlugin !== absoluteBase) {
+ throw Object.assign(new Error('Plugin path must be within allowed directory'), {
+ code: 'PATH_TRAVERSAL',
+ });
+ }
+ }
+}
+
+/**
+ * Validate source code input.
+ *
+ * @param sourceCode - Source code to validate
+ * @param sourcePath - Path for error messages
+ * @throws Error if validation fails
+ */
+function validateSourceCode(sourceCode: string, sourcePath: string): void {
+ if (typeof sourceCode !== 'string') {
+ throw Object.assign(new Error(`Invalid source code type for ${sanitizePath(sourcePath)}`), {
+ code: 'COMPILE_ERROR',
+ });
+ }
+
+ if (sourceCode.length === 0) {
+ throw Object.assign(new Error(`Source code is empty for ${sanitizePath(sourcePath)}`), {
+ code: 'COMPILE_ERROR',
+ });
+ }
+
+ if (sourceCode.length > MAX_SOURCE_SIZE) {
+ throw Object.assign(
+ new Error(
+ `Source code exceeds size limit (${(sourceCode.length / 1024 / 1024).toFixed(1)}MB > ${MAX_SOURCE_SIZE / 1024 / 1024}MB)`
+ ),
+ { code: 'SIZE_LIMIT' }
+ );
+ }
+}
+
+/**
+ * Validate compiled code output.
+ *
+ * @param code - Compiled code to validate
+ * @throws Error if validation fails
+ */
+function validateCompiledCode(code: string): void {
+ if (code.length > MAX_COMPILED_SIZE) {
+ throw Object.assign(
+ new Error(
+ `Compiled code exceeds size limit (${(code.length / 1024 / 1024).toFixed(1)}MB > ${MAX_COMPILED_SIZE / 1024 / 1024}MB)`
+ ),
+ { code: 'SIZE_LIMIT' }
+ );
+ }
+}
+
+/**
+ * Check if a file exists and is readable.
+ *
+ * @param filePath - Path to check
+ * @throws Error with specific code if file is not accessible
+ */
+async function validateFileAccess(filePath: string): Promise {
+ try {
+ await fs.promises.access(filePath, fs.constants.R_OK);
+ } catch (err) {
+ const error = err as NodeJS.ErrnoException;
+ if (error.code === 'ENOENT') {
+ throw Object.assign(new Error(`File not found: ${sanitizePath(filePath)}`), {
+ code: 'FILE_NOT_FOUND',
+ });
+ }
+ throw Object.assign(new Error(`File not readable: ${sanitizePath(filePath)}`), {
+ code: 'FILE_NOT_READABLE',
+ });
+ }
+}
+
+/**
+ * Run a promise with a timeout.
+ *
+ * @param promise - Promise to run
+ * @param timeoutMs - Timeout in milliseconds
+ * @param timeoutMessage - Error message on timeout
+ * @returns Promise result
+ */
+async function withTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ timeoutMessage: string
+): Promise {
+ let timeoutId: NodeJS.Timeout | undefined;
+
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(Object.assign(new Error(timeoutMessage), { code: 'TIMEOUT' }));
+ }, timeoutMs);
+ });
+
+ try {
+ const result = await Promise.race([promise, timeoutPromise]);
+ clearTimeout(timeoutId!);
+ return result;
+ } catch (err) {
+ clearTimeout(timeoutId!);
+ throw err;
+ }
+}
+
+/**
+ * Format an esbuild warning/error message
+ */
+function formatMessage(msg: Message, defaultPath: string): string {
+ const file = msg.location?.file
+ ? sanitizePath(msg.location.file)
+ : sanitizePath(defaultPath);
+ const line = msg.location?.line ?? 0;
+ const column = msg.location?.column ?? 0;
+ return `${file}:${line}:${column}: ${msg.text}`;
+}
+
+/**
+ * Compiles a TypeScript plugin file to JavaScript using esbuild with bundling.
+ * All imports are resolved and bundled into the output for self-contained execution.
+ *
+ * @param pluginPath - Path to the TypeScript plugin file
+ * @param options - Compilation options
+ * @returns Compiled JavaScript code and metadata
+ * @throws Error if compilation fails
+ *
+ * @example
+ * ```typescript
+ * const result = await compilePlugin('plugins/my-plugin.ts');
+ * console.log(result.code); // Compiled and bundled JavaScript
+ * ```
+ */
+export async function compilePlugin(
+ pluginPath: string,
+ options: CompilerOptions = {}
+): Promise {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+
+ // Security: validate path with base directory
+ const baseDir = opts.baseDir ?? DEFAULT_PLUGIN_BASE_DIR;
+ validatePathSecurity(pluginPath, baseDir);
+
+ // Resolve to absolute path
+ const absolutePath = path.isAbsolute(pluginPath)
+ ? pluginPath
+ : path.resolve(process.cwd(), pluginPath);
+
+ // Validate file exists and is readable
+ await validateFileAccess(absolutePath);
+
+ // Read source with size check
+ const stats = await fs.promises.stat(absolutePath);
+ if (stats.size > MAX_SOURCE_SIZE) {
+ throw Object.assign(
+ new Error(
+ `Source file exceeds size limit (${(stats.size / 1024 / 1024).toFixed(1)}MB > ${MAX_SOURCE_SIZE / 1024 / 1024}MB)`
+ ),
+ { code: 'SIZE_LIMIT' }
+ );
+ }
+
+ const sourceCode = await fs.promises.readFile(absolutePath, 'utf-8');
+
+ // Use the file's directory as resolveDir for proper import resolution
+ const resolveDir = opts.resolveDir ?? path.dirname(absolutePath);
+
+ return compilePluginSource(sourceCode, pluginPath, { ...opts, resolveDir });
+}
+
+/**
+ * Compiles TypeScript source code to JavaScript with bundling.
+ * This variant accepts source code directly (useful when code is already in memory).
+ * All imports are resolved and bundled into the output.
+ *
+ * @param sourceCode - TypeScript source code
+ * @param sourcePath - Original path (for error messages and source maps)
+ * @param options - Compilation options
+ * @returns Compiled JavaScript code and metadata
+ * @throws Error if compilation fails
+ *
+ * @example
+ * ```typescript
+ * const source = `
+ * import { ethers } from 'ethers';
+ * export async function handler({ api }) {
+ * return ethers.utils.formatEther('1000000000000000000');
+ * }
+ * `;
+ * const result = await compilePluginSource(source, 'inline-plugin.ts');
+ * // result.code contains bundled code with ethers included
+ * ```
+ */
+export async function compilePluginSource(
+ sourceCode: string,
+ sourcePath: string,
+ options: CompilerOptions = {}
+): Promise {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+
+ // Validate input
+ validateSourceCode(sourceCode, sourcePath);
+
+ const doCompile = async (): Promise => {
+ try {
+ // Use esbuild.build() with stdin to bundle imports
+ const result = await esbuild.build({
+ stdin: {
+ contents: sourceCode,
+ loader: 'ts',
+ sourcefile: sourcePath,
+ resolveDir: opts.resolveDir ?? process.cwd(),
+ },
+ bundle: true, // CRITICAL: Bundle to resolve and inline all imports
+ platform: 'node',
+ target: opts.target,
+ format: 'cjs', // CommonJS for Function constructor compatibility (executed via new Function())
+ sourcemap: opts.sourcemap === 'external' ? true : opts.sourcemap === 'inline' ? 'inline' : false,
+ minify: opts.minify,
+ write: false, // Keep in memory, don't write to disk
+ external: [
+ // Don't bundle Node.js built-ins - they're available in the sandbox
+ 'node:*',
+ // The SDK is already available in the sandbox
+ '@openzeppelin/relayer-sdk',
+ ],
+ logLevel: 'silent', // We handle errors ourselves
+ });
+
+ // Validate we got output
+ if (!result.outputFiles || result.outputFiles.length === 0) {
+ throw Object.assign(new Error('esbuild produced no output'), {
+ code: 'COMPILE_ERROR',
+ });
+ }
+
+ const code = result.outputFiles[0].text;
+
+ // Validate output size
+ validateCompiledCode(code);
+
+ // Format warnings
+ const warnings = result.warnings.map((w) => formatMessage(w, sourcePath));
+
+ // Handle source map based on configuration
+ let sourceMap: string | undefined;
+ if (opts.sourcemap === 'external' && result.outputFiles.length > 1) {
+ sourceMap = result.outputFiles[1].text;
+ }
+ // For 'inline', the source map is embedded in the code
+
+ return {
+ code,
+ sourceMap,
+ warnings,
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ // Check if it's already one of our errors
+ if ((error as any).code && VALID_ERROR_CODES.includes((error as any).code)) {
+ throw error;
+ }
+
+ // Sanitize error message - replace absolute paths with relative/safe versions
+ const sanitizedMessage = sanitizePath(error.message);
+
+ throw Object.assign(new Error(`Compilation failed: ${sanitizedMessage}`), {
+ code: 'COMPILE_ERROR',
+ });
+ }
+ throw error;
+ }
+ };
+
+ // Run with timeout
+ return withTimeout(
+ doCompile(),
+ opts.timeout ?? DEFAULT_COMPILE_TIMEOUT_MS,
+ `Compilation timed out after ${opts.timeout ?? DEFAULT_COMPILE_TIMEOUT_MS}ms`
+ );
+}
+
+/**
+ * Batch compile multiple plugins with concurrency limits.
+ * Returns both successful results and detailed error information.
+ *
+ * @param pluginPaths - Array of plugin file paths
+ * @param options - Compilation options
+ * @returns Map of successful results and array of errors
+ *
+ * @example
+ * ```typescript
+ * const { results, errors } = await compilePlugins(['a.ts', 'b.ts', 'c.ts']);
+ *
+ * // Handle successes
+ * for (const [path, result] of results) {
+ * console.log(`Compiled ${path}: ${result.code.length} bytes`);
+ * }
+ *
+ * // Handle errors
+ * for (const err of errors) {
+ * console.error(`${err.pluginPath}: [${err.code}] ${err.message}`);
+ * }
+ * ```
+ */
+export async function compilePlugins(
+ pluginPaths: string[],
+ options: CompilerOptions = {}
+): Promise {
+ const results = new Map();
+ const errors: CompilationError[] = [];
+
+ // Process in batches to limit concurrency
+ for (let i = 0; i < pluginPaths.length; i += MAX_CONCURRENT_COMPILATIONS) {
+ const batch = pluginPaths.slice(i, i + MAX_CONCURRENT_COMPILATIONS);
+
+ const compilations = await Promise.allSettled(
+ batch.map(async (pluginPath) => {
+ const result = await compilePlugin(pluginPath, options);
+ return { pluginPath, result };
+ })
+ );
+
+ // Process results with defensive checks
+ for (let j = 0; j < batch.length; j++) {
+ const pluginPath = batch[j];
+ const compilation = compilations[j];
+
+ if (!compilation) {
+ // Should never happen, but defensive programming
+ errors.push({
+ pluginPath,
+ message: 'Compilation result missing',
+ code: 'UNKNOWN',
+ });
+ continue;
+ }
+
+ if (compilation.status === 'fulfilled') {
+ results.set(compilation.value.pluginPath, compilation.value.result);
+ } else {
+ const error = compilation.reason as Error & { code?: string };
+ errors.push({
+ pluginPath,
+ message: error.message || 'Unknown error',
+ code: normalizeErrorCode(error.code),
+ });
+ }
+ }
+ }
+
+ return { results, errors };
+}
+
+/**
+ * Creates a wrapped version of compiled code that exports the handler.
+ *
+ * @deprecated This function is no longer used internally. The compiled code is executed
+ * directly via `new Function()` constructor in pool-executor.ts. This function is kept
+ * for backward compatibility but may be removed in a future version.
+ *
+ * @param compiledCode - The compiled JavaScript code
+ * @returns Wrapped code as an IIFE
+ * @throws Error if input is invalid
+ *
+ * @example
+ * ```typescript
+ * const compiled = await compilePluginSource(source, 'plugin.ts');
+ * const wrapped = wrapForVm(compiled.code);
+ * // Note: This is legacy code. Current execution uses new Function() directly.
+ * ```
+ */
+export function wrapForVm(compiledCode: string): string {
+ // Validate input
+ if (typeof compiledCode !== 'string') {
+ throw new Error('wrapForVm: compiledCode must be a string');
+ }
+
+ if (compiledCode.length === 0) {
+ throw new Error('wrapForVm: compiledCode cannot be empty');
+ }
+
+ if (compiledCode.length > MAX_COMPILED_SIZE) {
+ throw new Error(
+ `wrapForVm: code exceeds size limit (${(compiledCode.length / 1024 / 1024).toFixed(1)}MB)`
+ );
+ }
+
+ // The compiled code uses CommonJS (module.exports / exports.handler)
+ // We wrap it in an IIFE for legacy vm.Script compatibility
+ return `
+(function(exports, require, module, __filename, __dirname) {
+${compiledCode}
+});
+`;
+}
diff --git a/plugins/lib/constants.ts b/plugins/lib/constants.ts
new file mode 100644
index 000000000..4bb496a46
--- /dev/null
+++ b/plugins/lib/constants.ts
@@ -0,0 +1,35 @@
+/**
+ * Plugin Pool Constants
+ *
+ * IMPORTANT: These are fallback values only for standalone testing.
+ * In production, ALL configuration comes from Rust (src/services/plugins/config.rs)
+ * and is passed via environment variables when spawning the pool server.
+ *
+ * The single source of truth is: PLUGIN_MAX_CONCURRENCY
+ * Set it in .env and Rust derives everything else.
+ */
+
+// =============================================================================
+// Fallbacks for standalone testing (when not spawned by Rust)
+// =============================================================================
+
+/** Fallback minimum worker threads if not passed from Rust */
+export const DEFAULT_POOL_MIN_THREADS = 2;
+
+/** Fallback max threads if not passed from Rust */
+export const DEFAULT_POOL_MAX_THREADS_FLOOR = 8;
+
+/** Fallback concurrent tasks if not passed from Rust */
+export const DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER = 20;
+
+/** Fallback idle timeout if not passed from Rust */
+export const DEFAULT_POOL_IDLE_TIMEOUT_MS = 60000;
+
+/** Socket backlog for high concurrency */
+export const DEFAULT_POOL_SOCKET_BACKLOG = 2048;
+
+/** Default execution timeout (ms) */
+export const DEFAULT_POOL_EXECUTION_TIMEOUT_MS = 30000;
+
+/** Default per-request timeout for socket communication (ms) */
+export const DEFAULT_SOCKET_REQUEST_TIMEOUT_MS = 30000;
diff --git a/plugins/lib/executor.ts b/plugins/lib/executor.ts
index 015c0c545..b3c1334eb 100644
--- a/plugins/lib/executor.ts
+++ b/plugins/lib/executor.ts
@@ -1,12 +1,21 @@
#!/usr/bin/env node
/**
- * Plugin executor script for executing user plugins
+ * Legacy Plugin Executor (ts-node mode)
*
- * This is the main entry point for executing specific plugins from the Rust environment.
- * It serves as a bridge between the Rust relayer and TypeScript plugin ecosystem.
+ * **⚠️ Legacy/Fallback Implementation**
+ * This executor is used only when pool-based execution is disabled (`PLUGIN_USE_POOL=false`).
+ * The default execution mode uses the pool executor (`pool-executor.ts`), which is faster
+ * and more efficient. This ts-node executor spawns a new process per request, which is
+ * slower but simpler.
*
- * Called from: src/services/plugins/script_executor.rs
+ * **Default Execution Path**: Pool executor (`pool-executor.ts`) via `pool-server.ts` →
+ * `worker-pool.ts` → `pool-executor.ts` (Piscina workers)
+ *
+ * **Legacy Execution Path**: This executor (`executor.ts`) via `script_executor.rs` →
+ * ts-node → `executor.ts` (one-shot process per request)
+ *
+ * Called from: `src/services/plugins/script_executor.rs` (only when `PLUGIN_USE_POOL=false`)
* The Rust code invokes this script via ts-node and passes parameters as command line arguments.
*
* This script:
diff --git a/plugins/lib/kv.ts b/plugins/lib/kv.ts
index 89dbb8059..6716bc71f 100644
--- a/plugins/lib/kv.ts
+++ b/plugins/lib/kv.ts
@@ -85,6 +85,12 @@ export interface PluginKVStore {
fn: () => Promise,
opts?: { ttlSec?: number; onBusy?: 'throw' | 'skip' }
): Promise;
+
+ /**
+ * Disconnect from the backing store.
+ * Call this when the store is no longer needed to prevent connection leaks.
+ */
+ disconnect(): Promise;
}
/**
@@ -274,4 +280,20 @@ export class DefaultPluginKVStore implements PluginKVStore {
await this.client.eval(this.UNLOCK_SCRIPT, 1, lockKey, token);
}
}
+
+ /**
+ * Explicitly connect to Redis.
+ * Normally not needed since lazyConnect will connect on first command.
+ */
+ async connect(): Promise {
+ await this.client.connect();
+ }
+
+ /**
+ * Disconnect from Redis.
+ * Call this when the store is no longer needed.
+ */
+ async disconnect(): Promise {
+ await this.client.disconnect();
+ }
}
diff --git a/plugins/lib/logger.ts b/plugins/lib/logger.ts
index be1d7e20a..5880fe1c3 100644
--- a/plugins/lib/logger.ts
+++ b/plugins/lib/logger.ts
@@ -4,6 +4,29 @@ export interface LogEntry {
message: string;
}
+/**
+ * Safely stringify a value, handling circular references and BigInt.
+ * Falls back to String() if JSON.stringify fails.
+ */
+function safeStringify(value: unknown): string {
+ try {
+ return JSON.stringify(value, (_, v) => {
+ // Handle BigInt which JSON.stringify can't serialize
+ if (typeof v === 'bigint') {
+ return v.toString() + 'n';
+ }
+ return v;
+ });
+ } catch {
+ // Handle circular references or other stringify failures
+ try {
+ return String(value);
+ } catch {
+ return '[Unstringifiable value]';
+ }
+ }
+}
+
export class LogInterceptor {
private logs: LogEntry[] = [];
private originalConsole: {
@@ -34,13 +57,13 @@ export class LogInterceptor {
const message = args.map(arg =>
typeof arg === 'string' ? arg :
arg instanceof Error ? arg.message :
- JSON.stringify(arg)
+ safeStringify(arg)
).join(' ');
const logEntry: LogEntry = { level, message };
this.logs.push(logEntry);
- this.originalConsole.log(JSON.stringify(logEntry));
+ this.originalConsole.log(safeStringify(logEntry));
};
console.log = createLogger("log");
@@ -59,7 +82,7 @@ export class LogInterceptor {
message: message,
};
this.logs.push(logEntry);
- this.originalConsole.log(JSON.stringify(logEntry));
+ this.originalConsole.log(safeStringify(logEntry));
}
/**
diff --git a/plugins/lib/plugin.ts b/plugins/lib/plugin.ts
index 88f6b4db5..a41ed928c 100644
--- a/plugins/lib/plugin.ts
+++ b/plugins/lib/plugin.ts
@@ -43,9 +43,24 @@ import {
import { DefaultPluginKVStore } from './kv';
import { LogInterceptor } from './logger';
import type { PluginKVStore } from './kv';
+import { DEFAULT_SOCKET_REQUEST_TIMEOUT_MS } from './constants';
import net from 'node:net';
import { v4 as uuidv4 } from 'uuid';
+/**
+ * Custom error for socket closure - provides a recognizable error code
+ * for server-side connection termination (e.g., Rust's 60-second connection lifetime).
+ */
+class SocketClosedError extends Error {
+ code: string;
+
+ constructor(message: string) {
+ super(message);
+ this.name = 'SocketClosedError';
+ this.code = 'ESOCKETCLOSED';
+ }
+}
+
/**
* Smart serialization for plugin return values
* - Objects/Arrays: JSON.stringify (need serialization)
@@ -386,7 +401,27 @@ export async function loadAndExecutePlugin(
}
/**
- * The plugin API.
+ * Plugin API implementation for direct execution mode (ts-node).
+ *
+ * **⚠️ Legacy/Fallback Implementation**
+ * This implementation is used by `executor.ts` when Rust calls plugins via `ts-node` directly
+ * (see `src/services/plugins/script_executor.rs`). This is the legacy execution path, enabled
+ * only when `PLUGIN_USE_POOL=false`. The default execution mode uses the pool executor
+ * (`PluginAPIImpl` in `pool-executor.ts`), which is faster and more efficient.
+ *
+ * **Note**: New features should be added to the pool executor (`PluginAPIImpl`), not this
+ * implementation, as pool executor is the default and preferred path.
+ *
+ * **Why a separate implementation?**
+ * This implementation has different requirements than the worker pool implementation:
+ *
+ * - **Registration protocol**: Sends a `register` message with `execution_id` after connection
+ * (required by the direct execution protocol)
+ * - **One-shot lifecycle**: Process exits after plugin completes, so no socket pooling needed
+ * - **Immediate connection**: Connects in constructor since it's a single-use process
+ * - **Protocol support**: Handles both new protocol (`api_request`/`api_response`) and legacy format
+ *
+ * **See also**: `PluginAPIImpl` in `pool-executor.ts` for the worker pool implementation (default).
*
* @property useRelayer - Creates a relayer API for the given relayer ID.
* @property sendTransaction - Sends a transaction to the relayer.
@@ -398,6 +433,8 @@ export class DefaultPluginAPI implements PluginAPI {
private _connectionPromise: Promise | null = null;
private _connected: boolean = false;
private _httpRequestId?: string;
+ private _registered: boolean = false;
+ private _registrationPromise: Promise | null = null;
constructor(socketPath: string, httpRequestId?: string) {
this.socket = net.createConnection(socketPath);
@@ -405,15 +442,24 @@ export class DefaultPluginAPI implements PluginAPI {
this._httpRequestId = httpRequestId;
this._connectionPromise = new Promise((resolve, reject) => {
- this.socket.on('connect', () => {
+ this.socket.on('connect', async () => {
this._connected = true;
+ // Register with execution_id immediately after connection (new protocol)
+ await this._register();
resolve();
});
this.socket.on('error', (error) => {
console.error('Socket ERROR:', error);
+ this._rejectAllPending(error);
reject(error);
});
+
+ this.socket.on('close', () => {
+ this._connected = false;
+ // Use SocketClosedError so callers can identify server-side closure
+ this._rejectAllPending(new SocketClosedError('Socket closed by server (connection lifetime exceeded)'));
+ });
});
this.socket.on('data', (data) => {
@@ -422,15 +468,80 @@ export class DefaultPluginAPI implements PluginAPI {
.split('\n')
.filter(Boolean)
.forEach((msg: string) => {
- const parsed = JSON.parse(msg);
- const { requestId, result, error } = parsed;
- const resolver = this.pending.get(requestId);
- if (resolver) {
- error ? resolver.reject(error) : resolver.resolve(result);
- this.pending.delete(requestId);
+ try {
+ const parsed = JSON.parse(msg);
+
+ // Handle new protocol ApiResponse format
+ if (parsed.type === 'api_response') {
+ const { request_id, result, error } = parsed;
+ const resolver = this.pending.get(request_id);
+ if (resolver) {
+ error ? resolver.reject(new Error(error)) : resolver.resolve(result);
+ this.pending.delete(request_id);
+ }
+ }
+ // Fallback to legacy format for backward compatibility
+ else if (parsed.requestId) {
+ const { requestId, result, error } = parsed;
+ const resolver = this.pending.get(requestId);
+ if (resolver) {
+ error ? resolver.reject(error) : resolver.resolve(result);
+ this.pending.delete(requestId);
+ }
+ }
+ } catch {
+ // Ignore malformed messages
+ }
+ });
+ });
+ }
+
+ /**
+ * Register execution_id with the server (new protocol requirement)
+ * This must be the first message sent after connection
+ */
+ private async _register(): Promise {
+ if (this._registered || !this._httpRequestId) {
+ return;
+ }
+
+ if (this._registrationPromise) {
+ return this._registrationPromise;
+ }
+
+ this._registrationPromise = new Promise((resolve, reject) => {
+ const registerMsg = {
+ type: 'register',
+ execution_id: this._httpRequestId,
+ };
+ const message = JSON.stringify(registerMsg) + '\n';
+
+ try {
+ this.socket.write(message, (err) => {
+ if (err) {
+ reject(err);
+ return;
}
+ this._registered = true;
+ resolve();
});
+ } catch (err) {
+ reject(err);
+ }
});
+
+ return this._registrationPromise;
+ }
+
+ /**
+ * Reject all pending requests with the given error.
+ * Called on socket error or close to prevent hanging promises.
+ */
+ private _rejectAllPending(error: Error): void {
+ for (const [requestId, resolver] of this.pending.entries()) {
+ resolver.reject(error);
+ this.pending.delete(requestId);
+ }
}
/**
@@ -506,32 +617,80 @@ export class DefaultPluginAPI implements PluginAPI {
async _send(relayerId: string, method: string, payload: any): Promise {
const requestId = uuidv4();
- const msg: any = { requestId, relayerId, method, payload };
- if (this._httpRequestId) {
- msg.httpRequestId = this._httpRequestId;
- }
- const message = JSON.stringify(msg) + '\n';
+ // Ensure connection and registration are complete
if (!this._connected) {
await this._connectionPromise;
}
- const result = this.socket.write(message, (error) => {
- if (error) {
- console.error('Error sending message:', error);
- }
- });
+ // Ensure registration is sent (for new protocol)
+ // If httpRequestId is available, use new protocol; otherwise fall back to legacy
+ if (this._httpRequestId && !this._registered) {
+ await this._register();
+ }
- if (!result) {
- throw new Error(`Failed to send message to relayer: ${message}`);
+ // Use new protocol format if registered, otherwise fall back to legacy format
+ let msg: any;
+ if (this._registered && this._httpRequestId) {
+ // New protocol: ApiRequest message
+ msg = {
+ type: 'api_request',
+ request_id: requestId,
+ relayer_id: relayerId,
+ method: method,
+ payload: payload,
+ };
+ } else {
+ // Legacy protocol format (for backward compatibility when httpRequestId is missing)
+ msg = { requestId, relayerId, method, payload };
+ if (this._httpRequestId) {
+ msg.httpRequestId = this._httpRequestId;
+ }
}
+ const message = JSON.stringify(msg) + '\n';
return new Promise((resolve, reject) => {
- this.pending.set(requestId, { resolve, reject });
+ let timeoutId: NodeJS.Timeout | undefined;
+
+ // Set up timeout to prevent hanging forever
+ timeoutId = setTimeout(() => {
+ this.pending.delete(requestId);
+ reject(new Error(`Socket request '${method}' timed out after ${DEFAULT_SOCKET_REQUEST_TIMEOUT_MS}ms`));
+ }, DEFAULT_SOCKET_REQUEST_TIMEOUT_MS);
+
+ // Wrap resolvers to clear timeout on completion
+ this.pending.set(requestId, {
+ resolve: (value) => {
+ if (timeoutId) clearTimeout(timeoutId);
+ resolve(value);
+ },
+ reject: (reason) => {
+ if (timeoutId) clearTimeout(timeoutId);
+ reject(reason);
+ },
+ });
+
+ this.socket.write(message, (error) => {
+ if (error) {
+ if (timeoutId) clearTimeout(timeoutId);
+ this.pending.delete(requestId);
+ reject(error);
+ }
+ });
});
}
close() {
+ // Send shutdown message before closing (new protocol)
+ if (this._connected && this._registered) {
+ try {
+ const shutdownMsg = { type: 'shutdown' };
+ const message = JSON.stringify(shutdownMsg) + '\n';
+ this.socket.write(message);
+ } catch {
+ // Ignore errors when sending shutdown (socket might already be closed)
+ }
+ }
this.socket.end();
}
diff --git a/plugins/lib/pool-executor.ts b/plugins/lib/pool-executor.ts
new file mode 100644
index 000000000..5a78d6efc
--- /dev/null
+++ b/plugins/lib/pool-executor.ts
@@ -0,0 +1,914 @@
+/**
+ * Plugin Executor Module
+ *
+ * Piscina worker that executes compiled JavaScript plugins.
+ * Plugins run in worker threads for parallelism.
+ */
+
+import * as v8 from 'node:v8';
+import * as net from 'node:net';
+import { v4 as uuidv4 } from 'uuid';
+import { DefaultPluginKVStore } from './kv';
+import type { PluginAPI, PluginContext, PluginHeaders, Relayer } from './plugin';
+import {
+ ApiResponseRelayerResponseData,
+ ApiResponseRelayerStatusData,
+ JsonRpcRequestNetworkRpcRequest,
+ NetworkTransactionRequest,
+ SignTransactionResponse,
+ SignTransactionRequest,
+ TransactionResponse,
+ TransactionStatus,
+ pluginError,
+} from '@openzeppelin/relayer-sdk';
+import { DEFAULT_SOCKET_REQUEST_TIMEOUT_MS } from './constants';
+
+/**
+ * Function Cache - Caches compiled plugin factory functions.
+ * Compiling with Function constructor takes 1-5ms. Caching eliminates this for repeated code.
+ * Memory-aware: monitors heap usage and proactively evicts under pressure.
+ */
+class FunctionCache {
+ private cache = new Map();
+ private readonly maxSize = 100; // Max functions to cache per worker
+ private lastMemoryCheck = 0;
+ private readonly memoryCheckInterval = 5000; // Check every 5s
+
+ get(code: string): Function | undefined {
+ // Periodic memory check on get operations
+ this.maybeCheckMemory();
+
+ const entry = this.cache.get(code);
+ if (entry) {
+ // Update access timestamp for LRU
+ entry.timestamp = Date.now();
+ return entry.factory;
+ }
+ return undefined;
+ }
+
+ set(code: string, factory: Function): void {
+ // Check memory before adding
+ this.maybeCheckMemory();
+
+ // Evict oldest entry if at capacity (FIFO)
+ if (this.cache.size >= this.maxSize) {
+ this.evictOldest(1);
+ }
+ this.cache.set(code, { factory, timestamp: Date.now() });
+ }
+
+ /**
+ * Periodic memory check - evict cache if heap is under pressure.
+ */
+ private maybeCheckMemory(): void {
+ const now = Date.now();
+ if (now - this.lastMemoryCheck < this.memoryCheckInterval) {
+ return;
+ }
+ this.lastMemoryCheck = now;
+
+ const usage = process.memoryUsage();
+ const heapStats = v8.getHeapStatistics();
+ // Use heap_size_limit (the actual max heap) instead of heapTotal (current allocated)
+ // heapTotal grows dynamically and can be much smaller than the configured max,
+ // causing false positive pressure detection (e.g., 28MB/30MB = 93% when max is 26GB)
+ const heapUsedRatio = usage.heapUsed / heapStats.heap_size_limit;
+
+ // At 85% heap usage, evict 50% of cache
+ if (heapUsedRatio >= 0.85 && this.cache.size > 0) {
+ const evictCount = Math.max(1, Math.ceil(this.cache.size * 0.5));
+ console.warn(
+ `[worker-cache] HIGH memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` +
+ `evicting ${evictCount} functions`
+ );
+ this.evictOldest(evictCount);
+ } else if (heapUsedRatio >= 0.70 && this.cache.size > 0) {
+ // At 70% heap usage, evict 25% of cache
+ const evictCount = Math.max(1, Math.ceil(this.cache.size * 0.25));
+ console.warn(
+ `[worker-cache] Memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` +
+ `evicting ${evictCount} functions`
+ );
+ this.evictOldest(evictCount);
+ }
+ }
+
+ evictOldest(count: number): void {
+ // Sort by timestamp (oldest first) and evict
+ const entries = [...this.cache.entries()].sort(
+ (a, b) => a[1].timestamp - b[1].timestamp
+ );
+
+ let evicted = 0;
+ for (const [key] of entries) {
+ if (evicted >= count) break;
+ this.cache.delete(key);
+ evicted++;
+ }
+ if (evicted > 0) {
+ console.log(`[worker-cache] Evicted ${evicted} oldest functions`);
+ }
+ }
+
+ clear(): void {
+ // Emergency cleanup - drop all cached functions
+ const size = this.cache.size;
+ this.cache.clear();
+ if (size > 0) {
+ console.log(`[worker-cache] Emergency eviction: cleared ${size} cached functions`);
+ }
+ }
+
+ size(): number {
+ return this.cache.size;
+ }
+}
+
+/**
+ * Custom error for socket closure - enables retry logic to detect and handle
+ * server-side connection termination (e.g., Rust's 60-second connection lifetime).
+ */
+class SocketClosedError extends Error {
+ code: string;
+
+ constructor(message: string) {
+ super(message);
+ this.name = 'SocketClosedError';
+ this.code = 'ESOCKETCLOSED';
+ }
+}
+
+/**
+ * Pooled socket with creation timestamp for age-based eviction.
+ */
+interface PooledSocket {
+ socket: net.Socket;
+ createdAt: number;
+}
+
+/**
+ * Result of acquiring a socket from the pool.
+ */
+interface AcquiredSocket {
+ socket: net.Socket;
+ createdAt: number;
+}
+
+/**
+ * Socket Pool - Reuses socket connections across tasks in the same worker.
+ * Creating a socket takes 0.1-1ms. Pooling reduces this overhead.
+ *
+ * CRITICAL: The Rust server has a 60-second TOTAL CONNECTION LIFETIME (not idle).
+ * This means sockets created at T=0 will be closed at T=60, regardless of activity.
+ * We track per-socket creation time and discard sockets older than 50 seconds.
+ */
+class SocketPool {
+ private available: PooledSocket[] = [];
+ private readonly maxSize = 5; // Max sockets to cache per worker
+ // Rust server connection lifetime is 60s. Discard sockets older than 45s.
+ private readonly maxSocketAgeMs = 45_000;
+
+ acquire(): AcquiredSocket | null {
+ const now = Date.now();
+
+ // Pop sockets until we find a valid one or pool is empty
+ while (this.available.length > 0) {
+ const pooled = this.available.pop()!;
+ const age = now - pooled.createdAt;
+
+ // Discard sockets older than max age (Rust will close them soon anyway)
+ if (age > this.maxSocketAgeMs) {
+ pooled.socket.destroy();
+ continue;
+ }
+
+ // Check socket health
+ if (pooled.socket.writable && !pooled.socket.destroyed) {
+ return { socket: pooled.socket, createdAt: pooled.createdAt };
+ }
+
+ // Socket unhealthy, destroy and try next
+ pooled.socket.destroy();
+ }
+
+ // No valid socket found
+ return null;
+ }
+
+ /**
+ * Release a socket back to the pool.
+ * @param socket The socket to release
+ * @param createdAt When the socket was originally created (for age tracking)
+ */
+ release(socket: net.Socket, createdAt: number): void {
+ const now = Date.now();
+ const age = now - createdAt;
+
+ // Don't pool sockets that are already old or unhealthy
+ if (
+ age > this.maxSocketAgeMs ||
+ !socket.writable ||
+ socket.destroyed ||
+ this.available.length >= this.maxSize
+ ) {
+ socket.destroy();
+ return;
+ }
+
+ this.available.push({ socket, createdAt });
+ }
+
+ clear(): void {
+ // Clean up all pooled sockets
+ for (const pooled of this.available) {
+ pooled.socket.destroy();
+ }
+ this.available = [];
+ }
+}
+
+// Worker-level caches (thread-safe since Piscina workers are single-threaded)
+const functionCache = new FunctionCache();
+const socketPool = new SocketPool();
+
+/**
+ * Task payload received from the worker pool
+ */
+export interface ExecutorTask {
+ /** Unique task ID for correlation */
+ taskId: string;
+ /** Plugin ID for KV namespacing */
+ pluginId: string;
+ /** Pre-compiled JavaScript code */
+ compiledCode: string;
+ /** Plugin parameters */
+ params: any;
+ /** HTTP headers from incoming request */
+ headers?: PluginHeaders;
+ /** Unix socket path for relayer communication */
+ socketPath: string;
+ /** HTTP request ID for tracing */
+ httpRequestId?: string;
+ /** Execution timeout in milliseconds */
+ timeout: number;
+ /** Wildcard route path (e.g., "/verify" from "/api/v1/plugins/:id/*") */
+ route?: string;
+ /** Plugin configuration object */
+ config?: any;
+ /** HTTP method (GET, POST, etc.) */
+ method?: string;
+ /** Query parameters */
+ query?: any;
+}
+
+/**
+ * Result returned from plugin execution
+ */
+export interface ExecutorResult {
+ /** Task ID for correlation */
+ taskId: string;
+ /** Whether execution succeeded */
+ success: boolean;
+ /** Return value (if success) */
+ result?: any;
+ /** Error information (if failed) */
+ error?: {
+ message: string;
+ code?: string;
+ status?: number;
+ details?: any;
+ };
+ /** Captured console logs */
+ logs: LogEntry[];
+}
+
+export interface LogEntry {
+ level: 'error' | 'warn' | 'info' | 'log' | 'debug' | 'result';
+ message: string;
+}
+
+/**
+ * Plugin API implementation for worker pool execution mode (Piscina workers).
+ *
+ * **✅ Default Implementation (Preferred)**
+ * This is the default and preferred plugin execution path. It's used when plugins run in
+ * Piscina worker threads (see `pool-server.ts` → `worker-pool.ts` → `pool-executor.ts`).
+ * Pool mode is enabled by default for better performance and is the recommended execution
+ * mode for production deployments.
+ *
+ * **Why a separate implementation?**
+ * This implementation has different requirements than the legacy ts-node execution:
+ *
+ * - **Socket pooling**: Reuses sockets across multiple plugin executions in the same worker thread
+ * (critical for performance in high-concurrency scenarios)
+ * - **No registration protocol**: Worker pool uses a different communication model that doesn't
+ * require registration messages (simpler and more efficient)
+ * - **Lazy connection**: Connects only when first API call is made (better for worker lifecycle)
+ * - **EPIPE retry logic**: Handles stale pooled sockets that were closed by the server but client
+ * doesn't know yet (common with 60-second connection lifetime)
+ * - **Handler cleanup**: Properly removes socket listeners before returning to pool to prevent
+ * listener accumulation (MaxListenersExceededWarning)
+ *
+ * **See also**: `DefaultPluginAPI` in `plugin.ts` for the legacy ts-node execution implementation
+ * (fallback mode, enabled only when `PLUGIN_USE_POOL=false`).
+ */
+class PluginAPIImpl implements PluginAPI {
+ private socket: net.Socket | null = null;
+ private pending: Map void; reject: (reason: any) => void }>;
+ private connectionPromise: Promise | null = null;
+ private connected: boolean = false;
+ private httpRequestId?: string;
+ private socketPath: string;
+ private readonly maxPendingRequests = 100; // Prevent memory leak from unbounded pending
+ private socketCreatedAt: number = 0; // Track socket creation time for pool age-based eviction
+ private socketBuffer: string = ''; // Accumulator for TCP stream data (handles partial messages)
+
+ // Store handler references for proper cleanup (prevents listener accumulation)
+ private boundErrorHandler: ((error: Error) => void) | null = null;
+ private boundCloseHandler: (() => void) | null = null;
+ private boundDataHandler: ((data: Buffer) => void) | null = null;
+
+ constructor(socketPath: string, httpRequestId?: string) {
+ this.socketPath = socketPath;
+ this.pending = new Map();
+ this.httpRequestId = httpRequestId;
+ }
+
+ /**
+ * Ensure socket is connected.
+ * @param forceNewSocket If true, skip the pool and create a fresh socket.
+ * Used after retryable errors to avoid getting another stale socket.
+ */
+ private async ensureConnected(forceNewSocket: boolean = false): Promise {
+ if (this.connected) return;
+
+ if (!this.connectionPromise) {
+ // Try to get socket from pool first (unless forced to create new)
+ const acquired = forceNewSocket ? null : socketPool.acquire();
+
+ if (acquired) {
+ this.socket = acquired.socket;
+ this.socketCreatedAt = acquired.createdAt;
+ this.connected = true;
+ this.connectionPromise = Promise.resolve();
+
+ // Set up error/close handlers for pooled socket to enable reconnection
+ this.setupSocketHandlers(this.socket);
+ } else {
+ // Create new socket if pool is empty
+ this.socket = net.createConnection(this.socketPath);
+ this.socketCreatedAt = Date.now();
+
+ // Set up tracked handlers (can be removed later)
+ this.setupSocketHandlers(this.socket);
+
+ this.connectionPromise = new Promise((resolve, reject) => {
+ // 'connect' is one-time event, use once() so it auto-removes
+ this.socket!.once('connect', () => {
+ this.connected = true;
+ resolve();
+ });
+
+ // Additional one-time error handler for connection phase only
+ this.socket!.once('error', reject);
+ });
+ }
+ }
+
+ await this.connectionPromise;
+ }
+
+ /**
+ * Set up error/close/data handlers for a socket (pooled or new).
+ * This ensures sockets can trigger reconnection on failure.
+ * Stores references for proper cleanup to prevent listener accumulation.
+ */
+ private setupSocketHandlers(socket: net.Socket): void {
+ // Create bound handlers so they can be removed later
+ this.boundErrorHandler = (error: Error) => this.handleSocketError(error);
+ this.boundCloseHandler = () => this.handleSocketClose();
+ this.boundDataHandler = (data: Buffer) => this.handleSocketData(data);
+
+ socket.on('error', this.boundErrorHandler);
+ socket.on('close', this.boundCloseHandler);
+ socket.on('data', this.boundDataHandler);
+ }
+
+ /**
+ * Remove socket handlers before returning to pool.
+ * Prevents listener accumulation (MaxListenersExceededWarning).
+ * Also clears the socket buffer to prevent data leakage between executions.
+ */
+ private removeSocketHandlers(socket: net.Socket): void {
+ if (this.boundErrorHandler) {
+ socket.removeListener('error', this.boundErrorHandler);
+ this.boundErrorHandler = null;
+ }
+ if (this.boundCloseHandler) {
+ socket.removeListener('close', this.boundCloseHandler);
+ this.boundCloseHandler = null;
+ }
+ if (this.boundDataHandler) {
+ socket.removeListener('data', this.boundDataHandler);
+ this.boundDataHandler = null;
+ }
+ // Clear buffer to prevent data leakage between pool reuses
+ this.socketBuffer = '';
+ }
+
+ /**
+ * Handle socket error - reject pending requests and reset connection state
+ */
+ private handleSocketError(error: Error): void {
+ this.rejectAllPending(error);
+ // Reset connection state to force reconnection on next call
+ this.connected = false;
+ this.connectionPromise = null;
+ this.socket = null;
+ this.socketBuffer = ''; // Clear any partial data in buffer
+ }
+
+ /**
+ * Handle socket close - reset connection state to enable reconnection.
+ * Uses SocketClosedError to enable automatic retry in sendWithRetry().
+ */
+ private handleSocketClose(): void {
+ this.connected = false;
+ // Use SocketClosedError so retry logic can detect and handle this
+ this.rejectAllPending(new SocketClosedError('Socket closed by server (connection lifetime exceeded)'));
+ // Reset connection state to force reconnection on next call
+ this.connectionPromise = null;
+ this.socket = null;
+ this.socketBuffer = ''; // Clear any partial data in buffer
+ }
+
+ /**
+ * Handle incoming data from socket.
+ * Uses a persistent buffer to handle TCP stream fragmentation - data may arrive
+ * in chunks that don't align with message boundaries (newline-delimited JSON).
+ */
+ private handleSocketData(data: Buffer): void {
+ this.socketBuffer += data.toString();
+
+ // Extract and process complete messages (newline-delimited)
+ let newlineIndex;
+ while ((newlineIndex = this.socketBuffer.indexOf('\n')) !== -1) {
+ const line = this.socketBuffer.slice(0, newlineIndex);
+ this.socketBuffer = this.socketBuffer.slice(newlineIndex + 1);
+
+ if (!line) continue;
+
+ try {
+ const parsed = JSON.parse(line);
+ const { requestId, result, error } = parsed;
+ const resolver = this.pending.get(requestId);
+ if (resolver) {
+ error ? resolver.reject(error) : resolver.resolve(result);
+ this.pending.delete(requestId);
+ }
+ } catch (e) {
+ // Log parse errors for debugging - this shouldn't happen with complete messages
+ console.error('[PluginAPIImpl] Failed to parse response line:', line.substring(0, 200), e);
+ }
+ }
+ }
+
+ /**
+ * Reject all pending requests with the given error.
+ * Called on socket error or close to prevent hanging promises.
+ */
+ private rejectAllPending(error: Error): void {
+ for (const [requestId, resolver] of this.pending.entries()) {
+ resolver.reject(error);
+ this.pending.delete(requestId);
+ }
+ }
+
+ useRelayer(relayerId: string): Relayer {
+ return {
+ sendTransaction: async (payload: NetworkTransactionRequest) => {
+ const result = await this.send<{ id: string; relayer_id: string }>(relayerId, 'sendTransaction', payload);
+ return {
+ ...result,
+ wait: (options?: { interval?: number; timeout?: number }) =>
+ this.transactionWait(result, options),
+ } as any;
+ },
+ getTransaction: (payload: { transactionId: string }) =>
+ this.send(relayerId, 'getTransaction', payload),
+ getRelayerStatus: () =>
+ this.send(relayerId, 'getRelayerStatus', {}),
+ signTransaction: (payload: SignTransactionRequest) =>
+ this.send(relayerId, 'signTransaction', payload),
+ getRelayer: () =>
+ this.send(relayerId, 'getRelayer', {}),
+ rpc: (payload: JsonRpcRequestNetworkRpcRequest) =>
+ this.send(relayerId, 'rpc', payload),
+ };
+ }
+
+ async transactionWait(
+ transaction: { id: string; relayer_id: string },
+ options?: { interval?: number; timeout?: number }
+ ): Promise {
+ const interval = options?.interval ?? 5000;
+ const timeout = options?.timeout ?? 60000;
+ const relayer = this.useRelayer(transaction.relayer_id);
+ let shouldContinue = true;
+
+ const poll = async (): Promise => {
+ let tx = await relayer.getTransaction({ transactionId: transaction.id });
+ while (
+ shouldContinue &&
+ tx.status !== TransactionStatus.MINED &&
+ tx.status !== TransactionStatus.CONFIRMED &&
+ tx.status !== TransactionStatus.CANCELED &&
+ tx.status !== TransactionStatus.EXPIRED &&
+ tx.status !== TransactionStatus.FAILED
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ if (!shouldContinue) break;
+ tx = await relayer.getTransaction({ transactionId: transaction.id });
+ }
+ return tx;
+ };
+
+ let timeoutId: NodeJS.Timeout | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ shouldContinue = false;
+ reject(pluginError(`Transaction ${transaction.id} timed out after ${timeout}ms`, { status: 504 }));
+ }, timeout);
+ });
+
+ return Promise.race([poll(), timeoutPromise]).finally(() => {
+ shouldContinue = false;
+ if (timeoutId) clearTimeout(timeoutId);
+ });
+ }
+
+ private async send(relayerId: string, method: string, payload: any): Promise {
+ return this.sendWithRetry(relayerId, method, payload, false, false);
+ }
+
+ /**
+ * Send request with EPIPE retry logic.
+ * EPIPE occurs when the pooled socket was closed by the server but client doesn't know yet.
+ * On EPIPE, we destroy the stale socket and retry with a fresh connection.
+ *
+ * @param forceNewSocket If true, skip the socket pool and create a fresh connection.
+ * Used on retries to avoid getting another stale socket from pool.
+ */
+ private async sendWithRetry(
+ relayerId: string,
+ method: string,
+ payload: any,
+ isRetry: boolean,
+ forceNewSocket: boolean
+ ): Promise {
+ const requestId = uuidv4();
+ const msg: any = { requestId, relayerId, method, payload };
+ if (this.httpRequestId) {
+ msg.httpRequestId = this.httpRequestId;
+ }
+ const message = JSON.stringify(msg) + '\n';
+
+ // Check pending request limit to prevent memory leaks
+ if (this.pending.size >= this.maxPendingRequests) {
+ throw new Error(
+ `Too many concurrent API requests (max ${this.maxPendingRequests}). ` +
+ `Await previous requests before making new ones.`
+ );
+ }
+
+ // Ensure we're connected before sending
+ // On retry, force new socket to avoid getting another stale socket from pool
+ await this.ensureConnected(forceNewSocket);
+
+ try {
+ // Capture socket reference locally to guard against TOCTOU race condition.
+ // Socket can become null between ensureConnected() and write() if an error/close
+ // event fires asynchronously. This is especially likely after stress testing when
+ // pooled connections may be in a degraded state.
+ const socket = this.socket;
+ if (!socket) {
+ // Socket was nullified by error/close handler between ensureConnected and now.
+ // Treat as a stale connection error that can be retried.
+ const staleError = new Error('Socket became unavailable after connection') as NodeJS.ErrnoException;
+ staleError.code = 'ECONNRESET';
+ throw staleError;
+ }
+
+ return await new Promise((resolve, reject) => {
+ let timeoutId: NodeJS.Timeout | undefined;
+
+ // Set up timeout to prevent hanging forever
+ timeoutId = setTimeout(() => {
+ this.pending.delete(requestId);
+ reject(new Error(`Socket request '${method}' timed out after ${DEFAULT_SOCKET_REQUEST_TIMEOUT_MS}ms`));
+ }, DEFAULT_SOCKET_REQUEST_TIMEOUT_MS);
+
+ // Wrap resolvers to clear timeout on completion
+ this.pending.set(requestId, {
+ resolve: (value) => {
+ if (timeoutId) clearTimeout(timeoutId);
+ resolve(value);
+ },
+ reject: (reason) => {
+ if (timeoutId) clearTimeout(timeoutId);
+ reject(reason);
+ },
+ });
+
+ socket.write(message, (error) => {
+ if (error) {
+ if (timeoutId) clearTimeout(timeoutId);
+ this.pending.delete(requestId);
+ reject(error);
+ }
+ });
+ });
+ } catch (error: any) {
+ // Handle connection errors - stale socket from pool or server-side closure
+ // EPIPE: write to closed socket (client doesn't know server closed)
+ // ECONNRESET: connection reset by peer (server forcefully closed)
+ // ESOCKETCLOSED: server closed connection (e.g., 60-second lifetime expired)
+ const isRetryableError =
+ error.code === 'EPIPE' ||
+ error.code === 'ECONNRESET' ||
+ error.code === 'ESOCKETCLOSED';
+
+ if (!isRetry && isRetryableError) {
+ // Destroy the stale socket (don't return to pool)
+ if (this.socket) {
+ this.removeSocketHandlers(this.socket);
+ this.socket.destroy();
+ this.socket = null;
+ }
+ this.connected = false;
+ this.connectionPromise = null;
+
+ // Retry with fresh connection, forcing new socket to bypass pool
+ return this.sendWithRetry(relayerId, method, payload, true, true);
+ }
+ throw error;
+ }
+ }
+
+ close(): void {
+ // Return socket to pool if healthy, otherwise destroy
+ if (this.socket) {
+ // Remove handlers BEFORE returning to pool to prevent listener accumulation
+ this.removeSocketHandlers(this.socket);
+ socketPool.release(this.socket, this.socketCreatedAt);
+ this.socket = null;
+ }
+ this.connected = false;
+ }
+}
+
+/**
+ * Safely stringify a value, handling circular references and BigInt.
+ * Falls back to String() if JSON.stringify fails.
+ */
+function safeStringify(value: unknown): string {
+ try {
+ return JSON.stringify(value, (_, v) => {
+ if (typeof v === 'bigint') {
+ return v.toString() + 'n';
+ }
+ return v;
+ });
+ } catch {
+ try {
+ return String(value);
+ } catch {
+ return '[Unstringifiable value]';
+ }
+ }
+}
+
+/**
+ * Creates a console-like object that captures logs with lazy stringification.
+ * Stringification is deferred until logs are accessed to reduce overhead.
+ */
+function createPluginConsole(logs: LogEntry[]): Console {
+ const log = (level: LogEntry['level']) => (...args: any[]) => {
+ // Store raw args, stringify lazily when accessed
+ const entry: any = {
+ level,
+ _args: args, // Store raw args
+ _stringified: false,
+ _message: '',
+ };
+
+ // Lazy getter for message
+ Object.defineProperty(entry, 'message', {
+ get() {
+ if (!this._stringified) {
+ this._message = this._args.map((arg: any) =>
+ typeof arg === 'object' ? safeStringify(arg) : String(arg)
+ ).join(' ');
+ this._stringified = true;
+ // Clear raw args to free memory
+ delete this._args;
+ }
+ return this._message;
+ },
+ enumerable: true,
+ configurable: true,
+ });
+
+ logs.push(entry);
+ };
+
+ return {
+ log: log('log'),
+ info: log('info'),
+ warn: log('warn'),
+ error: log('error'),
+ debug: log('debug'),
+ trace: log('debug'),
+ // Required console methods (no-ops for unused ones)
+ assert: () => {},
+ clear: () => {},
+ count: () => {},
+ countReset: () => {},
+ dir: () => {},
+ dirxml: () => {},
+ group: () => {},
+ groupCollapsed: () => {},
+ groupEnd: () => {},
+ table: () => {},
+ time: () => {},
+ timeEnd: () => {},
+ timeLog: () => {},
+ timeStamp: () => {},
+ profile: () => {},
+ profileEnd: () => {},
+ Console: console.Console,
+ } as Console;
+}
+
+/**
+ * Executes a compiled plugin.
+ * This is the Piscina worker function.
+ * Uses function caching for performance.
+ */
+export default async function executePlugin(task: ExecutorTask): Promise {
+ const logs: LogEntry[] = [];
+ const api = new PluginAPIImpl(task.socketPath, task.httpRequestId);
+ const kv = new DefaultPluginKVStore(task.pluginId);
+
+ try {
+ // Create console that captures logs
+ const pluginConsole = createPluginConsole(logs);
+
+ // Create module-like objects for CommonJS compatibility
+ const moduleExports: any = {};
+ const moduleObject = { exports: moduleExports };
+
+ // Try to get cached factory function, otherwise compile and cache
+ let factory = functionCache.get(task.compiledCode);
+ if (!factory) {
+ // eslint-disable-next-line no-new-func
+ factory = new Function(
+ 'exports',
+ 'require',
+ 'module',
+ '__filename',
+ '__dirname',
+ 'console',
+ task.compiledCode
+ );
+ functionCache.set(task.compiledCode, factory);
+ }
+
+ // Execute the factory to populate module.exports
+ // Pass our custom console to capture logs
+ factory(moduleExports, require, moduleObject, `plugin-${task.pluginId}.js`, '/plugins', pluginConsole);
+
+ // Get the handler from exports
+ const handler = moduleObject.exports.handler || moduleExports.handler;
+
+ if (!handler || typeof handler !== 'function') {
+ throw new Error('Plugin must export a handler function');
+ }
+
+ // Execute the handler with timeout protection
+ // This prevents hung async handlers from blocking workers indefinitely
+ let result: any;
+
+ const handlerPromise = (async () => {
+ if (handler.length === 1) {
+ // Modern context-based handler
+ const pluginContext: PluginContext = {
+ api,
+ params: task.params,
+ kv,
+ headers: task.headers ?? {},
+ route: task.route ?? '',
+ config: task.config,
+ method: task.method ?? 'POST',
+ query: task.query ?? {},
+ };
+ return await handler(pluginContext);
+ } else {
+ // Legacy 2-param handler (no KV/headers access)
+ return await handler(api, task.params);
+ }
+ })();
+
+ // Race handler against timeout to prevent worker starvation
+ let timeoutId: NodeJS.Timeout | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ const error = new Error(`Plugin handler timed out after ${task.timeout}ms`);
+ (error as any).code = 'ERR_HANDLER_TIMEOUT';
+ reject(error);
+ }, task.timeout);
+ });
+
+ try {
+ result = await Promise.race([handlerPromise, timeoutPromise]);
+ } finally {
+ // Clear timeout if handler completed before timeout
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+
+ return {
+ taskId: task.taskId,
+ success: true,
+ result,
+ logs,
+ };
+ } catch (error) {
+ const err = error as any;
+
+ // Extract detailed error information
+ let errorCode = 'PLUGIN_ERROR';
+ let errorMessage = String(error);
+ let errorDetails: any = undefined;
+ let errorStatus = 500;
+
+ if (err && typeof err === 'object') {
+ errorMessage = err.message || String(error);
+
+ // Determine error code from error type
+ if (err.name === 'SyntaxError') {
+ errorCode = 'SYNTAX_ERROR';
+ } else if (err.name === 'TypeError') {
+ errorCode = 'TYPE_ERROR';
+ } else if (err.name === 'ReferenceError') {
+ errorCode = 'REFERENCE_ERROR';
+ } else if (err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
+ errorCode = 'TIMEOUT';
+ errorStatus = 504;
+ errorMessage = `Plugin module compilation timed out after ${task.timeout}ms`;
+ } else if (err.code === 'ERR_HANDLER_TIMEOUT') {
+ errorCode = 'TIMEOUT';
+ errorStatus = 504;
+ // Message already set by the timeout error
+ } else if (err.code) {
+ errorCode = err.code;
+ }
+
+ // Capture any additional details
+ if (err.details) {
+ errorDetails = err.details;
+ }
+
+ // Use status if provided
+ if (typeof err.status === 'number') {
+ errorStatus = err.status;
+ }
+ }
+
+ return {
+ taskId: task.taskId,
+ success: false,
+ error: {
+ message: errorMessage,
+ code: errorCode,
+ status: errorStatus,
+ details: errorDetails,
+ },
+ logs,
+ };
+ } finally {
+ // Close API socket (non-blocking, don't throw)
+ try {
+ api.close();
+ } catch {
+ // Log but don't fail - cleanup is best-effort
+ }
+
+ // Disconnect KV (async, don't throw)
+ try {
+ await kv.disconnect();
+ } catch {
+ // Ignore disconnect errors - connection may not have been established
+ }
+ }
+}
diff --git a/plugins/lib/pool-server.ts b/plugins/lib/pool-server.ts
new file mode 100644
index 000000000..98414d262
--- /dev/null
+++ b/plugins/lib/pool-server.ts
@@ -0,0 +1,1101 @@
+#!/usr/bin/env node
+
+/**
+ * Pool Server
+ *
+ * Long-running Node.js process that manages the Piscina worker pool.
+ * Communicates with Rust via Unix socket using JSON-line protocol.
+ *
+ * Protocol:
+ * - Each message is a single JSON line terminated by \n
+ * - Request: { type: "execute", taskId, pluginId, compiledCode?, pluginPath?, params, headers?, socketPath, httpRequestId?, timeout? }
+ * - Request: { type: "precompile", taskId, pluginId, pluginPath?, sourceCode? }
+ * - Request: { type: "cache", taskId, pluginId, compiledCode }
+ * - Request: { type: "invalidate", taskId, pluginId }
+ * - Request: { type: "stats", taskId }
+ * - Request: { type: "shutdown", taskId }
+ * - Response: { taskId, success, result?, error?, logs? }
+ *
+ * Configuration:
+ *
+ * All configuration is derived from PLUGIN_MAX_CONCURRENCY in Rust's config.rs
+ * and passed via environment variables when spawning this process.
+ * See: src/services/plugins/config.rs
+ *
+ * Environment Variables (passed from Rust):
+ * - PLUGIN_MAX_CONCURRENCY: Primary scaling knob
+ * - PLUGIN_POOL_MAX_THREADS: Worker threads (derived from concurrency)
+ * - PLUGIN_POOL_CONCURRENT_TASKS: Tasks per worker (derived from concurrency)
+ * - PLUGIN_POOL_IDLE_TIMEOUT: Idle timeout in ms
+ * - PLUGIN_POOL_DEBUG: Set to 'true' to enable debug logging
+ */
+
+import * as net from 'node:net';
+import * as fs from 'node:fs';
+import * as v8 from 'node:v8';
+import { WorkerPoolManager, type PluginExecutionRequest } from './worker-pool';
+import {
+ DEFAULT_POOL_MIN_THREADS,
+ DEFAULT_POOL_MAX_THREADS_FLOOR,
+ DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_POOL_SOCKET_BACKLOG,
+} from './constants';
+
+// Debug logging helper
+const DEBUG = process.env.PLUGIN_POOL_DEBUG === 'true';
+function debug(...args: any[]): void {
+ if (DEBUG) {
+ console.error('[pool-server]', ...args);
+ }
+}
+
+/**
+ * Memory Pressure Monitor - Pool Server Edition
+ * Monitors main process heap and logs warnings when approaching limits.
+ * Pool server manages workers, code cache, and socket communication.
+ *
+ * SELF-HEALING FEATURES:
+ * - Proactive GC triggering at 80% heap usage
+ * - Cache eviction at 85% heap usage
+ * - Emergency restart signal at 95% (tells Rust to restart)
+ *
+ * ASYNC SAFETY:
+ * - All callbacks are queued to event loop via setImmediate to avoid blocking the timer
+ * - Emergency shutdown is tracked to prevent duplicate invocations
+ */
+class PoolServerMemoryMonitor {
+ private checkInterval: NodeJS.Timeout | null = null;
+ private readonly warningThreshold = 0.75; // 75% of heap - start monitoring
+ private readonly gcThreshold = 0.80; // 80% of heap - force GC
+ private readonly criticalThreshold = 0.85; // 85% of heap - evict caches
+ private readonly emergencyThreshold = 0.92; // 92% of heap - signal restart
+ private lastWarningTime = 0;
+ private readonly warningCooldown = 10000; // 10 seconds between warnings (more frequent)
+ private consecutiveHighPressure = 0;
+ private onCacheEvictionRequest: (() => void) | null = null;
+ private onEmergencyRestart: (() => void) | null = null;
+ /** Tracks if emergency shutdown has already been triggered (prevents duplicate calls) */
+ private emergencyTriggered = false;
+
+ start(): void {
+ if (this.checkInterval) return;
+
+ // Check memory pressure every 5 seconds (more frequent for faster response)
+ this.checkInterval = setInterval(() => {
+ this.checkMemoryPressure();
+ }, 5000);
+
+ // Don't prevent process exit
+ this.checkInterval.unref();
+
+ console.error('[pool-server] Memory monitoring started (thresholds: 75%/80%/85%/92%)');
+ }
+
+ stop(): void {
+ if (this.checkInterval) {
+ clearInterval(this.checkInterval);
+ this.checkInterval = null;
+ }
+ }
+
+ /**
+ * Register callback for cache eviction requests.
+ */
+ onCacheEviction(callback: () => void): void {
+ this.onCacheEvictionRequest = callback;
+ }
+
+ /**
+ * Register callback for emergency restart signal.
+ */
+ onEmergency(callback: () => void): void {
+ this.onEmergencyRestart = callback;
+ }
+
+ private checkMemoryPressure(): void {
+ const usage = process.memoryUsage();
+ const heapStats = v8.getHeapStatistics();
+ const heapUsed = usage.heapUsed;
+ // Use heap_size_limit (V8's configured maximum heap) instead of heapTotal
+ // heapTotal is the currently allocated heap which grows dynamically,
+ // heap_size_limit is the actual maximum (e.g., from --max-old-space-size)
+ const heapLimit = heapStats.heap_size_limit;
+ const heapUsedRatio = heapUsed / heapLimit;
+
+ // Track consecutive high pressure for emergency detection
+ if (heapUsedRatio >= this.criticalThreshold) {
+ this.consecutiveHighPressure++;
+ } else if (heapUsedRatio < this.warningThreshold) {
+ this.consecutiveHighPressure = 0;
+ }
+
+ // Emergency: persistent critical pressure = GC can't keep up
+ if (heapUsedRatio >= this.emergencyThreshold || this.consecutiveHighPressure >= 6) {
+ this.handleEmergency(heapUsed, heapLimit, usage.external, usage.rss);
+ } else if (heapUsedRatio >= this.criticalThreshold) {
+ this.handleCriticalPressure(heapUsed, heapLimit, usage.external, usage.rss);
+ } else if (heapUsedRatio >= this.gcThreshold) {
+ this.handleGCPressure(heapUsed, heapLimit);
+ } else if (heapUsedRatio >= this.warningThreshold) {
+ this.handleWarningPressure(heapUsed, heapLimit, usage.external, usage.rss);
+ }
+ }
+
+ private handleEmergency(heapUsed: number, heapLimit: number, external: number, rss: number): void {
+ // Prevent duplicate emergency shutdowns
+ if (this.emergencyTriggered) {
+ return;
+ }
+ this.emergencyTriggered = true;
+
+ const heapUsedMB = Math.round(heapUsed / 1024 / 1024);
+ const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
+ const percent = Math.round((heapUsed / heapLimit) * 100);
+
+ console.error(
+ `[pool-server] EMERGENCY: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%). ` +
+ `Consecutive high pressure: ${this.consecutiveHighPressure}. ` +
+ `GC cannot keep up - signaling Rust for graceful restart.`
+ );
+
+ // Try last-ditch GC
+ if (global.gc) {
+ try {
+ global.gc();
+ } catch {
+ // Ignore
+ }
+ }
+
+ // Queue emergency callback to event loop to avoid blocking the timer
+ // This ensures the setInterval can continue and the async shutdown
+ // is handled properly without blocking other operations
+ if (this.onEmergencyRestart) {
+ setImmediate(() => {
+ this.onEmergencyRestart!();
+ });
+ }
+
+ // Reset counter after emergency signal
+ this.consecutiveHighPressure = 0;
+ }
+
+ private handleCriticalPressure(heapUsed: number, heapLimit: number, external: number, rss: number): void {
+ const now = Date.now();
+ if (now - this.lastWarningTime < this.warningCooldown) return;
+ this.lastWarningTime = now;
+
+ const heapUsedMB = Math.round(heapUsed / 1024 / 1024);
+ const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
+ const externalMB = Math.round(external / 1024 / 1024);
+ const rssMB = Math.round(rss / 1024 / 1024);
+ const percent = Math.round((heapUsed / heapLimit) * 100);
+
+ console.error(
+ `[pool-server] CRITICAL: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%). ` +
+ `External: ${externalMB}MB, RSS: ${rssMB}MB. ` +
+ `Requesting cache eviction and forcing GC.`
+ );
+
+ // Request cache eviction - queue to event loop to avoid blocking timer
+ if (this.onCacheEvictionRequest) {
+ setImmediate(() => {
+ this.onCacheEvictionRequest!();
+ });
+ }
+
+ // Force GC
+ if (global.gc) {
+ try {
+ global.gc();
+ console.error(`[pool-server] Forced GC triggered`);
+ } catch (err) {
+ console.error(`[pool-server] Failed to trigger GC:`, err);
+ }
+ }
+ }
+
+ private handleGCPressure(heapUsed: number, heapLimit: number): void {
+ // Silently trigger GC at 80% threshold
+ if (global.gc) {
+ try {
+ global.gc();
+ } catch {
+ // Ignore
+ }
+ }
+ }
+
+ private handleWarningPressure(heapUsed: number, heapLimit: number, external: number, rss: number): void {
+ const now = Date.now();
+ if (now - this.lastWarningTime < this.warningCooldown * 3) return; // Less frequent warnings
+ this.lastWarningTime = now;
+
+ const heapUsedMB = Math.round(heapUsed / 1024 / 1024);
+ const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
+ const percent = Math.round((heapUsed / heapLimit) * 100);
+
+ console.error(
+ `[pool-server] WARNING: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%).`
+ );
+ }
+}
+
+// Message types
+interface BaseMessage {
+ type: string;
+ taskId: string;
+}
+
+interface ExecuteMessage extends BaseMessage {
+ type: 'execute';
+ pluginId: string;
+ compiledCode?: string;
+ pluginPath?: string;
+ params: any;
+ headers?: Record;
+ socketPath: string;
+ httpRequestId?: string;
+ timeout?: number;
+ route?: string;
+ config?: any;
+ method?: string;
+ query?: any;
+}
+
+interface PrecompileMessage extends BaseMessage {
+ type: 'precompile';
+ pluginId: string;
+ pluginPath?: string;
+ sourceCode?: string;
+}
+
+interface CacheMessage extends BaseMessage {
+ type: 'cache';
+ pluginId: string;
+ compiledCode: string;
+}
+
+interface InvalidateMessage extends BaseMessage {
+ type: 'invalidate';
+ pluginId: string;
+}
+
+interface StatsMessage extends BaseMessage {
+ type: 'stats';
+}
+
+interface HealthMessage extends BaseMessage {
+ type: 'health';
+}
+
+interface ShutdownMessage extends BaseMessage {
+ type: 'shutdown';
+}
+
+type Message =
+ | ExecuteMessage
+ | PrecompileMessage
+ | CacheMessage
+ | InvalidateMessage
+ | StatsMessage
+ | HealthMessage
+ | ShutdownMessage;
+
+interface Response {
+ taskId: string;
+ success: boolean;
+ result?: any;
+ error?: {
+ message: string;
+ code?: string;
+ status?: number;
+ details?: any;
+ };
+ logs?: Array<{ level: string; message: string }>;
+}
+
+/**
+ * Pool Server - manages worker pool and handles requests from Rust
+ */
+class PoolServer {
+ private pool: WorkerPoolManager;
+ private server: net.Server | null = null;
+ private socketPath: string;
+ private running: boolean = false;
+ private shuttingDown: boolean = false;
+ private activeRequests: number = 0;
+ private readonly shutdownTimeoutMs: number = 30000; // 30 seconds max drain time
+
+ constructor(socketPath: string) {
+ this.socketPath = socketPath;
+
+ const cpuCount = require('os').cpus().length;
+
+ // =========================================================================
+ // Configuration - ALL values are passed from Rust (single source of truth)
+ // =========================================================================
+ // Rust's config.rs derives all values from PLUGIN_MAX_CONCURRENCY and
+ // passes them via environment variables when spawning this process.
+ //
+ // Fallbacks are provided only for manual testing without Rust.
+ //
+ const maxConcurrency = parseInt(process.env.PLUGIN_MAX_CONCURRENCY || '2048', 10);
+ const minThreads = parseInt(
+ process.env.PLUGIN_POOL_MIN_THREADS || String(Math.max(DEFAULT_POOL_MIN_THREADS, Math.floor(cpuCount / 2))),
+ 10
+ );
+ const maxThreads = parseInt(
+ process.env.PLUGIN_POOL_MAX_THREADS || String(Math.max(cpuCount, DEFAULT_POOL_MAX_THREADS_FLOOR)),
+ 10
+ );
+ const concurrentTasksPerWorker = parseInt(
+ process.env.PLUGIN_POOL_CONCURRENT_TASKS || String(DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER),
+ 10
+ );
+ const idleTimeout = parseInt(
+ process.env.PLUGIN_POOL_IDLE_TIMEOUT || String(DEFAULT_POOL_IDLE_TIMEOUT_MS),
+ 10
+ );
+
+ // Log effective configuration (received from Rust)
+ console.error(`[pool-server] Configuration (from Rust): maxConcurrency=${maxConcurrency}, minThreads=${minThreads}, maxThreads=${maxThreads}, concurrentTasksPerWorker=${concurrentTasksPerWorker}, idleTimeout=${idleTimeout}ms`);
+
+ // WorkerPoolManager handles cache lifecycle with active eviction (runs every 5 mins)
+ // Cache is properly cleaned up on shutdown via pool.destroy()
+ this.pool = new WorkerPoolManager({
+ minThreads,
+ maxThreads,
+ concurrentTasksPerWorker,
+ idleTimeout,
+ });
+ }
+
+ /**
+ * Start the pool server
+ */
+ async start(): Promise {
+ debug('Initializing worker pool...');
+ // Initialize the worker pool
+ await this.pool.initialize();
+ debug('Worker pool initialized');
+
+ // Clean up any existing socket file
+ try {
+ fs.unlinkSync(this.socketPath);
+ debug('Removed existing socket file');
+ } catch {
+ // Ignore if doesn't exist
+ }
+
+ // Create Unix socket server with high connection backlog for bursts
+ this.server = net.createServer((socket) => {
+ debug('net.createServer callback - new connection');
+ this.handleConnection(socket);
+ });
+
+
+ // Log any server-level errors
+ this.server.on('error', (err) => {
+ console.error('[pool-server] Server error:', err);
+ });
+
+ this.server.on('connection', (socket) => {
+ debug('Server connection event from:', socket.remoteAddress);
+ });
+
+ // Backlog for pending connections (default 511, increase for high concurrency)
+ // Note: Rust exports PLUGIN_POOL_SOCKET_BACKLOG with derived headroom
+ const backlog = parseInt(process.env.PLUGIN_POOL_SOCKET_BACKLOG || String(DEFAULT_POOL_SOCKET_BACKLOG), 10);
+
+ return new Promise((resolve, reject) => {
+ this.server!.on('error', (err) => {
+ console.error('[pool-server] Listen error:', err);
+ reject(err);
+ });
+
+ this.server!.listen(this.socketPath, backlog, () => {
+ this.running = true;
+ debug(`Listening on ${this.socketPath} (backlog=${backlog})`);
+
+ // Verify the socket file exists
+ try {
+ const stats = fs.statSync(this.socketPath);
+ debug(`Socket file verified: mode=${stats.mode.toString(8)}, isSocket=${stats.isSocket()}`);
+ } catch (e: any) {
+ console.warn(`[pool-server] Socket file check failed:`, e.message);
+ }
+
+ // Verify server state
+ const addr = this.server!.address();
+ debug(`Server address:`, addr);
+ debug(`Server listening:`, this.server!.listening);
+
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Handle a client connection
+ */
+ private handleConnection(socket: net.Socket): void {
+ const clientId = Math.random().toString(36).substring(7);
+ debug(`[${clientId}] Client connected`);
+
+ // Enable keep-alive to prevent connection drops
+ socket.setKeepAlive(true, 30000); // 30 second keep-alive probe
+ socket.setNoDelay(true); // Disable Nagle's algorithm for lower latency
+
+ let buffer = '';
+ let processingPromise: Promise | null = null;
+ const pendingLines: string[] = [];
+
+ // Max buffer size to prevent OOM from malicious clients (10MB)
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
+
+ const processQueue = async (): Promise => {
+ // Wait for any in-progress processing to complete (proper mutual exclusion)
+ if (processingPromise) {
+ await processingPromise;
+ }
+
+ if (pendingLines.length === 0) return;
+
+ processingPromise = (async () => {
+ while (pendingLines.length > 0) {
+ const line = pendingLines.shift()!;
+ if (!line.trim()) continue;
+
+ try {
+ const message = JSON.parse(line) as Message;
+ debug('Processing message type:', message.type);
+ const response = await this.handleMessage(message);
+ debug('Sending response for task:', response.taskId);
+ // Check if socket is still writable before writing
+ if (socket.writable) {
+ socket.write(JSON.stringify(response) + '\n');
+ } else {
+ debug('Socket no longer writable, discarding response');
+ }
+ } catch (err) {
+ const error = err as Error;
+ debug('Error handling message:', error);
+ const response: Response = {
+ taskId: 'unknown',
+ success: false,
+ error: {
+ message: error.message || 'Failed to parse message',
+ code: 'PARSE_ERROR',
+ },
+ };
+ if (socket.writable) {
+ socket.write(JSON.stringify(response) + '\n');
+ }
+ }
+ }
+ })();
+
+ await processingPromise;
+ processingPromise = null;
+ };
+
+ // Define handlers for proper cleanup on close
+ const dataHandler = (data: Buffer): void => {
+ buffer += data.toString();
+ debug(`[${clientId}] Received data: ${data.length} bytes, buffer: ${buffer.length}`);
+
+ // Buffer overflow protection - disconnect malicious clients
+ if (buffer.length > MAX_BUFFER_SIZE) {
+ console.error(`[pool-server] [${clientId}] Buffer overflow (${buffer.length} bytes), disconnecting`);
+ socket.destroy();
+ return;
+ }
+
+ debug(`[${clientId}] Buffer content: ${buffer.substring(0, 200)}...`);
+
+ // Extract complete messages (newline-delimited)
+ let newlineIndex;
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, newlineIndex);
+ buffer = buffer.slice(newlineIndex + 1);
+ pendingLines.push(line);
+ debug(`[${clientId}] Queued message: ${line.substring(0, 100)}...`);
+ }
+
+ // Process queue (async but don't await here)
+ processQueue().catch((err) => {
+ console.error(`[pool-server] [${clientId}] Error in processQueue:`, err);
+ // Send error response so client doesn't hang
+ const response: Response = {
+ taskId: 'unknown',
+ success: false,
+ error: { message: 'Internal queue processing error', code: 'QUEUE_ERROR' },
+ };
+ if (socket.writable) {
+ socket.write(JSON.stringify(response) + '\n');
+ }
+ });
+ };
+
+ const errorHandler = (err: Error): void => {
+ // Connection resets are normal during shutdown, don't log as errors
+ if ((err as any).code === 'ECONNRESET') {
+ debug(`[${clientId}] Connection reset`);
+ } else {
+ console.error(`[pool-server] [${clientId}] Socket error:`, err.message);
+ }
+ };
+
+ const closeHandler = (): void => {
+ // Explicit cleanup of listeners (good practice for long-running servers)
+ socket.removeListener('data', dataHandler);
+ socket.removeListener('error', errorHandler);
+ debug(`[${clientId}] Client disconnected, listeners cleaned up`);
+ };
+
+ socket.on('data', dataHandler);
+ socket.on('error', errorHandler);
+ socket.once('close', closeHandler);
+ }
+
+ /**
+ * Handle a message from Rust
+ */
+ private async handleMessage(message: Message): Promise {
+ const { taskId } = message;
+
+ // Allow shutdown and health messages during shutdown, reject others
+ if (this.shuttingDown && message.type !== 'shutdown' && message.type !== 'health') {
+ return {
+ taskId,
+ success: false,
+ error: {
+ message: 'Server is shutting down, not accepting new requests',
+ code: 'SHUTTING_DOWN',
+ },
+ };
+ }
+
+ // Track active requests for graceful shutdown draining
+ // Work requests get try/finally to ensure counter accuracy
+ const isWorkRequest = message.type === 'execute' || message.type === 'precompile';
+
+ if (isWorkRequest) {
+ this.activeRequests++;
+ try {
+ return await this.handleWorkRequest(message);
+ } finally {
+ this.activeRequests--;
+ }
+ }
+
+ // Non-work requests don't need tracking
+ return this.handleNonWorkRequest(message);
+ }
+
+ /**
+ * Handle work requests (execute, precompile) - tracked for graceful shutdown
+ */
+ private async handleWorkRequest(message: ExecuteMessage | PrecompileMessage): Promise {
+ const { taskId } = message;
+ try {
+ switch (message.type) {
+ case 'execute':
+ return await this.handleExecute(message);
+ case 'precompile':
+ return await this.handlePrecompile(message);
+ default: {
+ // TypeScript exhaustiveness check
+ const _exhaustive: never = message;
+ return {
+ taskId,
+ success: false,
+ error: { message: 'Unknown work request type', code: 'UNKNOWN_TYPE' },
+ };
+ }
+ }
+ } catch (err) {
+ const error = err as Error;
+ return {
+ taskId,
+ success: false,
+ error: {
+ message: error.message || String(err),
+ code: 'HANDLER_ERROR',
+ },
+ };
+ }
+ }
+
+ /**
+ * Handle non-work requests (cache, invalidate, stats, health, shutdown)
+ */
+ private async handleNonWorkRequest(message: Message): Promise {
+ const { taskId } = message;
+ try {
+ switch (message.type) {
+ case 'cache':
+ return this.handleCache(message as CacheMessage);
+ case 'invalidate':
+ return this.handleInvalidate(message as InvalidateMessage);
+ case 'stats':
+ return this.handleStats(message as StatsMessage);
+ case 'health':
+ return this.handleHealth(message as HealthMessage);
+ case 'shutdown':
+ return await this.handleShutdown(message as ShutdownMessage);
+ default:
+ return {
+ taskId,
+ success: false,
+ error: {
+ message: `Unknown message type: ${(message as any).type}`,
+ code: 'UNKNOWN_MESSAGE_TYPE',
+ },
+ };
+ }
+ } catch (err) {
+ const error = err as Error;
+ return {
+ taskId,
+ success: false,
+ error: {
+ message: error.message || String(err),
+ code: 'HANDLER_ERROR',
+ },
+ };
+ }
+ }
+
+ /**
+ * Execute a plugin
+ */
+ private async handleExecute(message: ExecuteMessage): Promise {
+ debug('handleExecute called with:', JSON.stringify({
+ pluginId: message.pluginId,
+ pluginPath: message.pluginPath,
+ hasCompiledCode: !!message.compiledCode,
+ socketPath: message.socketPath,
+ timeout: message.timeout,
+ }));
+
+ const request: PluginExecutionRequest = {
+ pluginId: message.pluginId,
+ pluginPath: message.pluginPath,
+ compiledCode: message.compiledCode,
+ params: message.params,
+ headers: message.headers,
+ socketPath: message.socketPath,
+ httpRequestId: message.httpRequestId,
+ timeout: message.timeout,
+ route: message.route,
+ config: message.config,
+ method: message.method,
+ query: message.query,
+ };
+
+ try {
+ debug('Calling pool.runPlugin...');
+ const result = await this.pool.runPlugin(request);
+ debug('runPlugin returned:', JSON.stringify({
+ success: result.success,
+ hasResult: !!result.result,
+ hasError: !!result.error,
+ }));
+
+ return {
+ taskId: message.taskId,
+ success: result.success,
+ result: result.result,
+ error: result.error,
+ logs: result.logs,
+ };
+ } catch (err) {
+ debug('runPlugin threw error:', err);
+ const error = err as any;
+
+ // Provide detailed error context
+ return {
+ taskId: message.taskId,
+ success: false,
+ error: {
+ message: error?.message || String(err),
+ code: error?.code || 'POOL_EXECUTION_ERROR',
+ status: typeof error?.status === 'number' ? error.status : 500,
+ details: {
+ pluginId: message.pluginId,
+ errorType: error?.name || 'Error',
+ },
+ },
+ logs: [],
+ };
+ }
+ }
+
+ /**
+ * Precompile a plugin
+ */
+ private async handlePrecompile(message: PrecompileMessage): Promise {
+ let compilationResult;
+
+ if (message.sourceCode) {
+ compilationResult = await this.pool.precompilePluginSource(
+ message.pluginId,
+ message.sourceCode
+ );
+ } else if (message.pluginPath) {
+ compilationResult = await this.pool.precompilePlugin(message.pluginPath);
+ } else {
+ return {
+ taskId: message.taskId,
+ success: false,
+ error: {
+ message: 'Either sourceCode or pluginPath is required for precompilation',
+ code: 'MISSING_SOURCE',
+ },
+ };
+ }
+
+ return {
+ taskId: message.taskId,
+ success: true,
+ result: {
+ code: compilationResult.code,
+ warnings: compilationResult.warnings,
+ },
+ };
+ }
+
+ /**
+ * Cache compiled code
+ */
+ private handleCache(message: CacheMessage): Response {
+ this.pool.cacheCompiledCode(message.pluginId, message.compiledCode);
+ return {
+ taskId: message.taskId,
+ success: true,
+ };
+ }
+
+ /**
+ * Invalidate cached plugin
+ */
+ private handleInvalidate(message: InvalidateMessage): Response {
+ this.pool.invalidatePlugin(message.pluginId);
+ return {
+ taskId: message.taskId,
+ success: true,
+ };
+ }
+
+ /**
+ * Get pool statistics
+ */
+ private handleStats(message: StatsMessage): Response {
+ const stats = this.pool.getStats();
+ return {
+ taskId: message.taskId,
+ success: true,
+ result: stats,
+ };
+ }
+
+ /**
+ * Health check - returns basic health status
+ */
+ private handleHealth(message: HealthMessage): Response {
+ const stats = this.pool.getStats();
+ const memUsage = process.memoryUsage();
+
+ return {
+ taskId: message.taskId,
+ success: true,
+ result: {
+ status: 'healthy',
+ uptime: stats.uptime,
+ memory: {
+ heapUsed: memUsage.heapUsed,
+ heapTotal: memUsage.heapTotal,
+ rss: memUsage.rss,
+ },
+ pool: stats.pool ? {
+ completed: stats.pool.completed,
+ queued: stats.pool.queued,
+ } : null,
+ execution: {
+ total: stats.execution.total,
+ successRate: stats.execution.successRate,
+ },
+ },
+ };
+ }
+
+ /**
+ * Shutdown the server gracefully
+ * 1. Stop accepting new requests
+ * 2. Wait for in-flight requests to complete (with timeout)
+ * 3. Clean up resources and exit
+ */
+ private async handleShutdown(message: ShutdownMessage): Promise {
+ if (this.shuttingDown) {
+ return {
+ taskId: message.taskId,
+ success: true,
+ result: { status: 'already_shutting_down', activeRequests: this.activeRequests },
+ };
+ }
+
+ console.error(`[pool-server] Graceful shutdown initiated, ${this.activeRequests} active requests`);
+ this.shuttingDown = true;
+
+ // Wait for in-flight requests to drain, then shutdown
+ // This runs asynchronously - we send the response first
+ this.drainAndExit().catch((err) => {
+ console.error('[pool-server] Error during graceful shutdown:', err);
+ process.exit(1);
+ });
+
+ return {
+ taskId: message.taskId,
+ success: true,
+ result: {
+ status: 'draining',
+ activeRequests: this.activeRequests,
+ timeoutMs: this.shutdownTimeoutMs,
+ },
+ };
+ }
+
+ /**
+ * Wait for active requests to complete, then exit
+ */
+ private async drainAndExit(): Promise {
+ const startTime = Date.now();
+ const checkInterval = 100; // Check every 100ms
+
+ // Wait for requests to drain
+ while (this.activeRequests > 0) {
+ const elapsed = Date.now() - startTime;
+ if (elapsed >= this.shutdownTimeoutMs) {
+ console.error(
+ `[pool-server] Shutdown timeout (${this.shutdownTimeoutMs}ms) reached, ` +
+ `${this.activeRequests} requests still active - forcing shutdown`
+ );
+ break;
+ }
+
+ // Log progress every 5 seconds
+ if (elapsed > 0 && elapsed % 5000 < checkInterval) {
+ console.error(
+ `[pool-server] Draining: ${this.activeRequests} requests remaining ` +
+ `(${Math.round(elapsed / 1000)}s / ${this.shutdownTimeoutMs / 1000}s)`
+ );
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
+ }
+
+ const drainTime = Date.now() - startTime;
+ console.error(`[pool-server] Drain complete in ${drainTime}ms, proceeding with shutdown`);
+
+ await this.stop();
+ process.exit(0);
+ }
+
+ /**
+ * Evict code cache under memory pressure.
+ * Called by memory monitor when heap usage is critical.
+ */
+ evictCache(): void {
+ try {
+ this.pool.clearCache();
+ console.error('[pool-server] Code cache evicted due to memory pressure');
+ } catch (err) {
+ console.error('[pool-server] Failed to evict cache:', err);
+ }
+ }
+
+ /**
+ * Emergency shutdown due to memory pressure.
+ * Uses shorter timeout but same drain logic for consistency.
+ * Exit code 1 indicates abnormal termination (not 137 which is SIGKILL).
+ */
+ async emergencyShutdown(): Promise {
+ if (this.shuttingDown) {
+ // Already shutting down, just wait
+ return;
+ }
+
+ console.error(`[pool-server] Emergency shutdown initiated, ${this.activeRequests} active requests`);
+ this.shuttingDown = true;
+
+ // Shorter timeout for emergency (10 seconds vs normal 30)
+ const emergencyTimeoutMs = 10000;
+ const startTime = Date.now();
+ const checkInterval = 100;
+
+ // Wait for requests to drain with shorter timeout
+ while (this.activeRequests > 0) {
+ const elapsed = Date.now() - startTime;
+ if (elapsed >= emergencyTimeoutMs) {
+ console.error(
+ `[pool-server] Emergency shutdown timeout (${emergencyTimeoutMs}ms) reached, ` +
+ `${this.activeRequests} requests still active - forcing exit`
+ );
+ break;
+ }
+
+ if (elapsed > 0 && elapsed % 2000 < checkInterval) {
+ console.error(
+ `[pool-server] Emergency draining: ${this.activeRequests} requests remaining`
+ );
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
+ }
+
+ const drainTime = Date.now() - startTime;
+ console.error(`[pool-server] Emergency drain complete in ${drainTime}ms`);
+
+ await this.stop();
+ // Exit code 1 = abnormal termination (OOM-like condition)
+ // Note: 137 is SIGKILL which is incorrect for voluntary exit
+ process.exit(1);
+ }
+
+ /**
+ * Stop the server
+ */
+ async stop(): Promise {
+ this.running = false;
+
+ if (this.server) {
+ await new Promise((resolve) => {
+ this.server!.close(() => resolve());
+ });
+ this.server = null;
+ }
+
+ await this.pool.shutdown();
+
+ // Clean up socket file
+ try {
+ fs.unlinkSync(this.socketPath);
+ } catch {
+ // Ignore
+ }
+
+ console.log('Pool server stopped');
+ }
+}
+
+// Main entry point
+async function main(): Promise {
+ const socketPath = process.argv[2] || process.env.PLUGIN_POOL_SOCKET || '/tmp/plugin-pool.sock';
+
+ debug('Starting with socket path:', socketPath);
+
+ // Log heap configuration from NODE_OPTIONS
+ const nodeOptions = process.env.NODE_OPTIONS || '';
+ const maxOldSpaceMatch = nodeOptions.match(/--max-old-space-size=(\d+)/);
+ const configuredHeapMB = maxOldSpaceMatch ? parseInt(maxOldSpaceMatch[1], 10) : 'default';
+ const currentHeapMB = Math.round(process.memoryUsage().heapTotal / 1024 / 1024);
+
+ console.info(
+ `[pool-server] Heap configuration: ${configuredHeapMB}MB max ` +
+ `(current: ${currentHeapMB}MB, will grow as needed)`
+ );
+
+ const server = new PoolServer(socketPath);
+
+ // Start memory monitoring for pool server process
+ const memoryMonitor = new PoolServerMemoryMonitor();
+
+ // Connect memory monitor to pool for cache eviction
+ memoryMonitor.onCacheEviction(() => {
+ console.error('[pool-server] Memory pressure - clearing code cache');
+ server.evictCache();
+ });
+
+ // Connect emergency handler - graceful shutdown to trigger Rust restart
+ memoryMonitor.onEmergency(() => {
+ console.error('[pool-server] Emergency memory pressure - initiating graceful shutdown for restart');
+ // Reuse the same graceful shutdown logic as normal shutdown
+ // This ensures active requests are drained properly (with timeout)
+ // Rust will detect the exit and restart the process
+ server.emergencyShutdown().catch((err) => {
+ console.error('[pool-server] Error during emergency shutdown:', err);
+ process.exit(1);
+ });
+ });
+
+ memoryMonitor.start();
+
+ // Handle uncaught exceptions to prevent silent crashes
+ process.on('uncaughtException', async (err) => {
+ console.error('[pool-server] Uncaught exception:', err);
+ try {
+ await server.stop();
+ } catch (stopErr) {
+ console.error('[pool-server] Error during cleanup:', stopErr);
+ }
+ process.exit(1);
+ });
+
+ process.on('unhandledRejection', async (reason, promise) => {
+ console.error('[pool-server] Unhandled rejection at:', promise, 'reason:', reason);
+ try {
+ await server.stop();
+ } catch (stopErr) {
+ console.error('[pool-server] Error during cleanup:', stopErr);
+ }
+ process.exit(1);
+ });
+
+ // Handle signals for graceful shutdown
+ process.on('SIGINT', async () => {
+ debug('Received SIGINT, shutting down...');
+ await server.stop();
+ process.exit(0);
+ });
+
+ process.on('SIGTERM', async () => {
+ debug('Received SIGTERM, shutting down...');
+ await server.stop();
+ process.exit(0);
+ });
+
+ try {
+ await server.start();
+ debug('Server started successfully, listening on', socketPath);
+ // Write ready signal to stdout for Rust to detect
+ console.log('POOL_SERVER_READY');
+
+ // Keep the process alive forever - the server will handle connections
+ debug('Entering event loop, waiting for connections...');
+
+ // Periodic heartbeat to verify event loop is running (debug mode only)
+ if (process.env.PLUGIN_POOL_DEBUG) {
+ let heartbeatCount = 0;
+ setInterval(() => {
+ heartbeatCount++;
+ if (heartbeatCount <= 5 || heartbeatCount % 60 === 0) {
+ debug(`Heartbeat #${heartbeatCount} - event loop is running`);
+ }
+ }, 1000);
+ }
+
+ // Keep process alive
+ await new Promise(() => {});
+ } catch (err) {
+ console.error('[pool-server] Failed to start pool server:', err);
+ process.exit(1);
+ }
+}
+
+main().catch((err) => {
+ console.error('[pool-server] Fatal error in main:', err);
+ process.exit(1);
+});
diff --git a/plugins/lib/worker-pool.ts b/plugins/lib/worker-pool.ts
new file mode 100644
index 000000000..eb6f04cd1
--- /dev/null
+++ b/plugins/lib/worker-pool.ts
@@ -0,0 +1,1046 @@
+/**
+ * Worker Pool Manager
+ *
+ * Manages a Piscina worker pool for executing compiled plugins.
+ * Provides a high-level interface for running plugins with proper lifecycle management.
+ *
+ * ## Performance Tuning
+ *
+ * ### Memory Configuration
+ * Each worker thread runs with an increased heap size (512MB via resourceLimits) to prevent
+ * OOM errors under high load. Node.js v8 defaults to ~96MB heap per worker, which is
+ * insufficient when handling many concurrent plugin contexts (e.g., 2500+ VUs).
+ *
+ * Symptoms of insufficient heap:
+ * - "FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory"
+ * - Workers crash during v8::internal::ContextDeserializer::Deserialize
+ * - Queue builds up (>50% capacity warnings) then all requests fail
+ *
+ * ### Thread Scaling
+ * - Worker threads scale dynamically based on PLUGIN_MAX_CONCURRENCY
+ * - Formula: max(cpuCount * 2, concurrency / 50, 8) with 64 thread cap
+ * - Each worker handles multiple concurrent tasks via Node.js async I/O
+ */
+
+import Piscina from 'piscina';
+import * as path from 'node:path';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as v8 from 'node:v8';
+import { v4 as uuidv4 } from 'uuid';
+import { compilePlugin, compilePluginSource, type CompilationResult } from './compiler';
+import type { ExecutorTask, ExecutorResult, LogEntry } from './pool-executor';
+import type { PluginHeaders } from './plugin';
+import {
+ DEFAULT_POOL_MIN_THREADS,
+ DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ DEFAULT_POOL_EXECUTION_TIMEOUT_MS,
+ DEFAULT_POOL_MAX_THREADS_FLOOR,
+ DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
+} from './constants';
+
+/**
+ * Worker pool configuration options
+ */
+export interface WorkerPoolOptions {
+ /** Minimum number of worker threads to maintain */
+ minThreads?: number;
+ /** Maximum number of worker threads */
+ maxThreads?: number;
+ /** Number of concurrent tasks per worker */
+ concurrentTasksPerWorker?: number;
+ /** Idle timeout before shutting down excess workers (ms) */
+ idleTimeout?: number;
+ /** Task-level timeout to prevent stuck workers (ms). Defaults to execution timeout + 5s buffer. */
+ taskTimeout?: number;
+}
+
+/**
+ * Plugin execution request
+ */
+export interface PluginExecutionRequest {
+ /** Plugin ID */
+ pluginId: string;
+ /** Path to plugin source file (for on-demand compilation) */
+ pluginPath?: string;
+ /** Pre-compiled JavaScript code (if already compiled) */
+ compiledCode?: string;
+ /** Plugin parameters */
+ params: any;
+ /** HTTP headers from incoming request */
+ headers?: PluginHeaders;
+ /** Unix socket path for relayer communication */
+ socketPath: string;
+ /** HTTP request ID for tracing */
+ httpRequestId?: string;
+ /** Execution timeout in milliseconds */
+ timeout?: number;
+ /** Wildcard route path (e.g., "/verify" from "/api/v1/plugins/:id/*") */
+ route?: string;
+ /** Plugin configuration object */
+ config?: any;
+ /** HTTP method (GET, POST, etc.) */
+ method?: string;
+ /** Query parameters */
+ query?: any;
+}
+
+/**
+ * Plugin execution result
+ */
+export interface PluginExecutionResult {
+ /** Whether execution succeeded */
+ success: boolean;
+ /** Return value (if success) */
+ result?: any;
+ /** Error information (if failed) */
+ error?: {
+ message: string;
+ code?: string;
+ status?: number;
+ details?: any;
+ };
+ /** Captured console logs */
+ logs: LogEntry[];
+}
+
+const DEFAULT_TIMEOUT = DEFAULT_POOL_EXECUTION_TIMEOUT_MS;
+
+// Task timeout includes a 5s buffer over execution timeout for cleanup overhead
+const DEFAULT_TASK_TIMEOUT = DEFAULT_TIMEOUT + 5000;
+
+const DEFAULT_OPTIONS: Required = {
+ minThreads: DEFAULT_POOL_MIN_THREADS,
+ maxThreads: Math.max(os.cpus().length, DEFAULT_POOL_MAX_THREADS_FLOOR),
+ concurrentTasksPerWorker: DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
+ idleTimeout: DEFAULT_POOL_IDLE_TIMEOUT_MS,
+ taskTimeout: DEFAULT_TASK_TIMEOUT,
+};
+
+/**
+ * Path to the pre-compiled sandbox executor.
+ * This file is generated at build time by running: npx ts-node build-executor.ts
+ */
+const PRECOMPILED_EXECUTOR_PATH = path.resolve(__dirname, 'pool-executor.js');
+
+/**
+ * Metrics tracking for plugin execution
+ */
+interface PluginMetrics {
+ // Execution metrics
+ totalExecutions: number;
+ successfulExecutions: number;
+ failedExecutions: number;
+
+ // Timing metrics (in ms)
+ totalExecutionTime: number;
+ minExecutionTime: number;
+ maxExecutionTime: number;
+
+ // Cache metrics
+ cacheHits: number;
+ cacheMisses: number;
+
+ // Compilation metrics
+ totalCompilations: number;
+ totalCompilationTime: number;
+
+ // Per-plugin execution counts
+ pluginExecutions: Map;
+
+ // Error tracking
+ errorsByType: Map;
+
+ // Timestamps
+ startTime: number;
+ lastExecutionTime: number | null;
+}
+
+/**
+ * Create initial metrics state
+ */
+function createInitialMetrics(): PluginMetrics {
+ return {
+ totalExecutions: 0,
+ successfulExecutions: 0,
+ failedExecutions: 0,
+ totalExecutionTime: 0,
+ minExecutionTime: Infinity,
+ maxExecutionTime: 0,
+ cacheHits: 0,
+ cacheMisses: 0,
+ totalCompilations: 0,
+ totalCompilationTime: 0,
+ pluginExecutions: new Map(),
+ errorsByType: new Map(),
+ startTime: Date.now(),
+ lastExecutionTime: null,
+ };
+}
+
+/**
+ * Increment a counter in a bounded Map.
+ * If the map exceeds maxSize, removes the oldest entry (FIFO eviction).
+ */
+function incrementBoundedMap(
+ map: Map,
+ key: string,
+ maxSize: number
+): void {
+ map.set(key, (map.get(key) || 0) + 1);
+
+ // Evict oldest entries if over limit
+ if (map.size > maxSize) {
+ const firstKey = map.keys().next().value;
+ if (firstKey !== undefined) {
+ map.delete(firstKey);
+ }
+ }
+}
+
+/**
+ * In-memory cache for compiled plugin code
+ * Includes memory pressure awareness for proactive eviction
+ */
+class CompiledCodeCache {
+ private cache: Map = new Map();
+ private maxAge: number;
+ private cleanupInterval!: NodeJS.Timeout;
+ private memoryCheckInterval!: NodeJS.Timeout;
+ private totalCacheSize: number = 0;
+ // Max cache size in bytes (100MB default - adjust under memory pressure)
+ private maxCacheSize: number = 100 * 1024 * 1024;
+
+ constructor(maxAgeMs: number = 3600000) {
+ // 1 hour default
+ this.maxAge = maxAgeMs;
+ this.startCleanupTimer();
+ this.startMemoryPressureMonitor();
+ }
+
+ /**
+ * Start periodic cleanup timer to evict expired entries.
+ * Runs every 5 minutes to prevent memory leaks.
+ */
+ private startCleanupTimer(): void {
+ this.cleanupInterval = setInterval(() => {
+ this.evictExpired();
+ }, 300000); // 5 minutes
+ // unref() allows process to exit even if timer is active
+ this.cleanupInterval.unref();
+ }
+
+ /**
+ * Start memory pressure monitoring - proactively evict cache under pressure.
+ * This helps prevent GC issues under high load.
+ */
+ private startMemoryPressureMonitor(): void {
+ this.memoryCheckInterval = setInterval(() => {
+ this.checkMemoryPressure();
+ }, 10000); // Every 10 seconds
+ this.memoryCheckInterval.unref();
+ }
+
+ /**
+ * Check memory pressure and evict cache entries proactively.
+ */
+ private checkMemoryPressure(): void {
+ const usage = process.memoryUsage();
+ const heapStats = v8.getHeapStatistics();
+ // Use heap_size_limit (the actual max heap) instead of heapTotal (current allocated)
+ // heapTotal grows dynamically and can be much smaller than the configured max,
+ // causing false positive pressure detection (e.g., 28MB/30MB = 93% when max is 26GB)
+ const heapUsedRatio = usage.heapUsed / heapStats.heap_size_limit;
+
+ // At 70% heap usage, start evicting oldest entries
+ if (heapUsedRatio >= 0.70) {
+ const evictCount = Math.ceil(this.cache.size * 0.25); // Evict 25%
+ if (evictCount > 0) {
+ console.warn(
+ `[cache] Memory pressure detected (${Math.round(heapUsedRatio * 100)}% of heap limit), ` +
+ `evicting ${evictCount} oldest entries`
+ );
+ this.evictOldest(evictCount);
+
+ // Force GC if available
+ if (global.gc) {
+ try {
+ global.gc();
+ } catch {
+ // Ignore
+ }
+ }
+ }
+ }
+
+ // At 85% heap usage, be more aggressive
+ if (heapUsedRatio >= 0.85) {
+ const evictCount = Math.ceil(this.cache.size * 0.5); // Evict 50%
+ if (evictCount > 0) {
+ console.error(
+ `[cache] HIGH memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` +
+ `evicting ${evictCount} entries`
+ );
+ this.evictOldest(evictCount);
+
+ // Force GC
+ if (global.gc) {
+ try {
+ global.gc();
+ } catch {
+ // Ignore
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Evict all expired entries from the cache.
+ */
+ private evictExpired(): void {
+ const now = Date.now();
+ let evictedCount = 0;
+ for (const [key, entry] of this.cache.entries()) {
+ if (now - entry.timestamp > this.maxAge) {
+ // Check delete return value to prevent size drift
+ if (this.cache.delete(key)) {
+ this.totalCacheSize -= entry.size;
+ evictedCount++;
+ }
+ }
+ }
+ if (evictedCount > 0) {
+ console.log(`[cache] Evicted ${evictedCount} expired entries`);
+ }
+ }
+
+ /**
+ * Evict N oldest entries (LRU-style) - used under memory pressure.
+ */
+ private evictOldest(count: number): void {
+ // Sort by timestamp (oldest first)
+ const entries = [...this.cache.entries()].sort(
+ (a, b) => a[1].timestamp - b[1].timestamp
+ );
+
+ let evicted = 0;
+ for (const [key, entry] of entries) {
+ if (evicted >= count) break;
+ // Check delete return value to prevent size drift
+ if (this.cache.delete(key)) {
+ this.totalCacheSize -= entry.size;
+ evicted++;
+ }
+ }
+
+ if (evicted > 0) {
+ console.log(`[cache] Evicted ${evicted} oldest entries under memory pressure`);
+ }
+ }
+
+ get(pluginPath: string): string | undefined {
+ const entry = this.cache.get(pluginPath);
+ if (!entry) return undefined;
+
+ // Check if expired
+ if (Date.now() - entry.timestamp > this.maxAge) {
+ if (this.cache.delete(pluginPath)) {
+ this.totalCacheSize -= entry.size;
+ }
+ return undefined;
+ }
+
+ // Update timestamp for LRU tracking
+ // Note: Timestamp update is racy under concurrent access but acceptable
+ // for LRU approximation - lost updates only cause slightly suboptimal eviction
+ entry.timestamp = Date.now();
+
+ return entry.code;
+ }
+
+ set(pluginPath: string, code: string): void {
+ const size = Buffer.byteLength(code, 'utf8');
+
+ // Evict if adding this would exceed max cache size
+ while (this.totalCacheSize + size > this.maxCacheSize && this.cache.size > 0) {
+ this.evictOldest(1);
+ }
+
+ const existing = this.cache.get(pluginPath);
+ if (existing) {
+ this.totalCacheSize -= existing.size;
+ }
+
+ this.cache.set(pluginPath, { code, timestamp: Date.now(), size });
+ this.totalCacheSize += size;
+ }
+
+ delete(pluginPath: string): boolean {
+ const entry = this.cache.get(pluginPath);
+ if (entry) {
+ this.totalCacheSize -= entry.size;
+ }
+ return this.cache.delete(pluginPath);
+ }
+
+ clear(): void {
+ this.cache.clear();
+ this.totalCacheSize = 0;
+ }
+
+ has(pluginPath: string): boolean {
+ return this.get(pluginPath) !== undefined;
+ }
+
+ /**
+ * Get current cache stats.
+ */
+ stats(): { entries: number; totalSizeBytes: number; maxSizeBytes: number } {
+ return {
+ entries: this.cache.size,
+ totalSizeBytes: this.totalCacheSize,
+ maxSizeBytes: this.maxCacheSize,
+ };
+ }
+
+ /**
+ * Destroy the cache and stop cleanup timer.
+ */
+ destroy(): void {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval);
+ }
+ if (this.memoryCheckInterval) {
+ clearInterval(this.memoryCheckInterval);
+ }
+ this.clear();
+ }
+}
+
+/**
+ * Worker Pool Manager
+ *
+ * Manages plugin execution using a Piscina worker pool.
+ * Handles compilation caching, task routing, and pool lifecycle.
+ */
+/** Maximum entries in metrics Maps to prevent unbounded growth */
+const MAX_METRICS_ENTRIES = 1000;
+
+export class WorkerPoolManager {
+ private pool: Piscina | null = null;
+ private options: Required;
+ private compiledCache: CompiledCodeCache;
+ private initialized: boolean = false;
+ /** Promise for in-flight initialization to prevent race conditions */
+ private initPromise: Promise | null = null;
+ private compiledWorkerPath: string | null = null;
+ /** Whether compiledWorkerPath is a temp file that should be cleaned up */
+ private isTemporaryWorkerFile: boolean = false;
+ private metrics: PluginMetrics;
+
+ // Event handlers stored as class properties for proper cleanup
+ private poolErrorHandler: ((err: Error) => void) | null = null;
+ private poolExitHandler: ((workerId: number, exitCode: number) => void) | null = null;
+
+ constructor(options: WorkerPoolOptions = {}) {
+ this.options = { ...DEFAULT_OPTIONS, ...options };
+ this.compiledCache = new CompiledCodeCache();
+ this.metrics = createInitialMetrics();
+
+ // Register cleanup handlers for unclean shutdown (temp file leak prevention)
+ this.registerCleanupHandlers();
+ }
+
+ /**
+ * Cleanup handler for process exit - stored as property for removal
+ */
+ private cleanupHandler = (): void => {
+ if (this.isTemporaryWorkerFile && this.compiledWorkerPath) {
+ try {
+ fs.unlinkSync(this.compiledWorkerPath);
+ } catch {
+ // Ignore - best effort cleanup
+ }
+ }
+ };
+
+ /**
+ * Register process handlers to clean up temp files on unexpected exit.
+ */
+ private registerCleanupHandlers(): void {
+ // Handle various exit scenarios
+ process.once('beforeExit', this.cleanupHandler);
+ process.once('SIGINT', this.cleanupHandler);
+ process.once('SIGTERM', this.cleanupHandler);
+ }
+
+ /**
+ * Remove process cleanup handlers (called during shutdown)
+ */
+ private removeCleanupHandlers(): void {
+ process.off('beforeExit', this.cleanupHandler);
+ process.off('SIGINT', this.cleanupHandler);
+ process.off('SIGTERM', this.cleanupHandler);
+ }
+
+ /**
+ * Initialize the worker pool.
+ * Call this before executing any plugins.
+ *
+ * Uses pre-compiled pool-executor.js if available,
+ * otherwise compiles it on-the-fly (slower first startup).
+ *
+ * Thread-safe: multiple concurrent calls will await the same initialization.
+ */
+ async initialize(): Promise {
+ // Already initialized
+ if (this.initialized) return;
+
+ // Another call is initializing - await it (prevents race condition)
+ if (this.initPromise) {
+ await this.initPromise;
+ return;
+ }
+
+ // Start initialization and store promise for concurrent callers
+ this.initPromise = this.doInitialize();
+
+ try {
+ await this.initPromise;
+ } finally {
+ // Clear promise after completion (success or failure)
+ this.initPromise = null;
+ }
+ }
+
+ /**
+ * Internal initialization logic.
+ */
+ private async doInitialize(): Promise {
+ // Use pre-compiled pool-executor.js if it exists
+ if (fs.existsSync(PRECOMPILED_EXECUTOR_PATH)) {
+ this.compiledWorkerPath = PRECOMPILED_EXECUTOR_PATH;
+ this.isTemporaryWorkerFile = false;
+ } else {
+ // Fallback: compile on-the-fly (for fresh checkouts, dev mode, etc.)
+ console.warn(
+ `[pool] Pre-compiled sandbox executor not found at ${PRECOMPILED_EXECUTOR_PATH}. ` +
+ `Compiling on-the-fly. Run 'npm run build:executor' in plugins/ for faster startup.`
+ );
+ this.compiledWorkerPath = await this.compileExecutorOnTheFly();
+ this.isTemporaryWorkerFile = true;
+ }
+
+ // Worker heap size from Rust config (based on concurrent tasks per worker)
+ // Formula: 512 + (concurrent_tasks * 8) MB - ensures enough heap for burst context creation
+ // Default to 2048MB if not passed (conservative for high load)
+ const workerHeapMb = parseInt(process.env.PLUGIN_WORKER_HEAP_MB || '2048', 10);
+
+ console.error(`[worker-pool] Worker heap size: ${workerHeapMb}MB (per worker thread)`);
+
+ this.pool = new Piscina({
+ filename: this.compiledWorkerPath,
+ minThreads: this.options.minThreads,
+ maxThreads: this.options.maxThreads,
+ concurrentTasksPerWorker: this.options.concurrentTasksPerWorker,
+ idleTimeout: this.options.idleTimeout,
+ recordTiming: true, // Unless you need waitTime/runTime metrics
+ // Worker heap size scaled by concurrent tasks per worker
+ // Each vm.createContext() uses ~4-8MB, plus GC headroom
+ // Set by Rust via PLUGIN_WORKER_HEAP_MB env var
+ resourceLimits: {
+ maxOldGenerationSizeMb: workerHeapMb,
+ },
+ });
+
+ // Track worker crashes for self-healing monitoring
+ let workerCrashCount = 0;
+ let lastCrashReset = Date.now();
+ const CRASH_WINDOW_MS = 60000; // 1 minute window for crash rate monitoring
+
+ // Store event handlers as class properties for proper cleanup on shutdown
+ this.poolErrorHandler = (err: Error) => {
+ console.error('[worker-pool] Worker error (will be replaced):', err.message);
+ // Track errors that indicate memory issues
+ const errMsg = err.message.toLowerCase();
+ if (errMsg.includes('heap') || errMsg.includes('memory') || errMsg.includes('allocation')) {
+ console.error('[worker-pool] Memory-related worker error detected - consider increasing worker heap size');
+ }
+ // Don't throw - Piscina handles recovery
+ };
+
+ this.poolExitHandler = (workerId: number, exitCode: number) => {
+ // Reset crash counter periodically
+ const now = Date.now();
+ if (now - lastCrashReset > CRASH_WINDOW_MS) {
+ workerCrashCount = 0;
+ lastCrashReset = now;
+ }
+
+ if (exitCode !== 0) {
+ workerCrashCount++;
+ console.error(
+ `[worker-pool] Worker ${workerId} exited with code ${exitCode} ` +
+ `(replaced automatically, crashes in window: ${workerCrashCount})`
+ );
+
+ // Detect crash storms (many workers dying = likely OOM or GC issue)
+ if (workerCrashCount >= 3) {
+ console.error(
+ `[worker-pool] WARNING: ${workerCrashCount} worker crashes in ${CRASH_WINDOW_MS}ms - ` +
+ `possible memory pressure. Consider reducing PLUGIN_MAX_CONCURRENCY or increasing heap.`
+ );
+ }
+
+ // Exit codes that indicate memory issues
+ if (exitCode === 134 || exitCode === 137 || exitCode === 9) {
+ console.error(
+ `[worker-pool] Worker ${workerId} killed with signal (code ${exitCode}) - ` +
+ `likely OOM. Clearing script cache as mitigation.`
+ );
+ // Clear cache as mitigation
+ this.compiledCache.clear();
+ }
+ }
+ };
+
+ // Listen to worker events for monitoring
+ this.pool.on('error', this.poolErrorHandler);
+ this.pool.on('workerExit', this.poolExitHandler);
+
+ this.initialized = true;
+ }
+
+ /**
+ * Compile the sandbox executor on-the-fly when pre-compiled version is not available.
+ * This is slower but ensures the pool can start in any environment.
+ */
+ private async compileExecutorOnTheFly(): Promise {
+ const sandboxExecutorPath = path.resolve(__dirname, 'pool-executor.ts');
+
+ const esbuild = await import('esbuild');
+ const result = await esbuild.build({
+ entryPoints: [sandboxExecutorPath],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ sourcemap: false,
+ write: false,
+ loader: { '.ts': 'ts' },
+ external: ['node:*'],
+ });
+
+ // Write to temp file
+ const tempPath = path.join(os.tmpdir(), `pool-executor-${uuidv4()}.js`);
+ fs.writeFileSync(tempPath, result.outputFiles[0].text);
+ return tempPath;
+ }
+
+ /**
+ * Pre-compile a plugin and cache the result.
+ *
+ * @param pluginPath - Path to the plugin source file
+ * @param pluginId - Optional plugin identifier for caching
+ * @returns Compilation result
+ */
+ async precompilePlugin(pluginPath: string, pluginId?: string): Promise {
+ const startTime = Date.now();
+ const result = await compilePlugin(pluginPath);
+ const compilationTime = Date.now() - startTime;
+
+ this.metrics.totalCompilations++;
+ this.metrics.totalCompilationTime += compilationTime;
+
+ // Cache by pluginId if provided, otherwise by path (single key to avoid duplication)
+ const cacheKey = pluginId || pluginPath;
+ this.compiledCache.set(cacheKey, result.code);
+ return result;
+ }
+
+ /**
+ * Pre-compile a plugin from source code and cache the result.
+ *
+ * @param pluginId - Plugin identifier for caching
+ * @param sourceCode - TypeScript source code
+ * @returns Compilation result
+ */
+ async precompilePluginSource(
+ pluginId: string,
+ sourceCode: string
+ ): Promise {
+ const startTime = Date.now();
+ const result = await compilePluginSource(sourceCode, `${pluginId}.ts`);
+ const compilationTime = Date.now() - startTime;
+
+ this.metrics.totalCompilations++;
+ this.metrics.totalCompilationTime += compilationTime;
+
+ this.compiledCache.set(pluginId, result.code);
+ return result;
+ }
+
+ /**
+ * Store pre-compiled code in the cache.
+ * Useful when compiled code comes from external storage (Redis).
+ *
+ * @param pluginId - Plugin identifier
+ * @param compiledCode - Pre-compiled JavaScript code
+ */
+ cacheCompiledCode(pluginId: string, compiledCode: string): void {
+ this.compiledCache.set(pluginId, compiledCode);
+ }
+
+ /**
+ * Get compiled code from cache.
+ *
+ * @param pluginId - Plugin identifier
+ * @returns Compiled code or undefined if not cached
+ */
+ getCachedCode(pluginId: string): string | undefined {
+ return this.compiledCache.get(pluginId);
+ }
+
+ /**
+ * Execute a plugin in a worker.
+ *
+ * @param request - Plugin execution request
+ * @returns Plugin execution result
+ */
+ async runPlugin(request: PluginExecutionRequest): Promise {
+ if (!this.initialized || !this.pool) {
+ await this.initialize();
+ }
+
+ // Verify pool exists after init (defensive - initialize() should throw on failure)
+ if (!this.pool) {
+ return {
+ success: false,
+ error: {
+ message: 'Worker pool failed to initialize',
+ code: 'POOL_INIT_FAILED',
+ status: 500,
+ },
+ logs: [],
+ };
+ }
+
+ const executionStartTime = Date.now();
+ let cacheHit = false;
+
+ // Get compiled code (from request, cache, or compile on-demand)
+ let compiledCode = request.compiledCode;
+
+ if (!compiledCode && request.pluginPath) {
+ // Try cache first
+ compiledCode = this.compiledCache.get(request.pluginPath);
+
+ if (compiledCode) {
+ cacheHit = true;
+ this.metrics.cacheHits++;
+ } else {
+ // Compile on-demand
+ this.metrics.cacheMisses++;
+ const compileStartTime = Date.now();
+ const result = await compilePlugin(request.pluginPath);
+ this.metrics.totalCompilations++;
+ this.metrics.totalCompilationTime += Date.now() - compileStartTime;
+ compiledCode = result.code;
+ this.compiledCache.set(request.pluginPath, compiledCode);
+ }
+ }
+
+ if (!compiledCode) {
+ // Try by plugin ID
+ compiledCode = this.compiledCache.get(request.pluginId);
+ if (compiledCode) {
+ cacheHit = true;
+ this.metrics.cacheHits++;
+ } else {
+ this.metrics.cacheMisses++;
+ }
+ }
+
+ if (!compiledCode) {
+ this.metrics.totalExecutions++;
+ this.metrics.failedExecutions++;
+ const errorCode = 'NO_COMPILED_CODE';
+ incrementBoundedMap(this.metrics.errorsByType, errorCode, MAX_METRICS_ENTRIES);
+ return {
+ success: false,
+ error: {
+ message: `No compiled code available for plugin ${request.pluginId}`,
+ code: errorCode,
+ status: 500,
+ },
+ logs: [],
+ };
+ }
+
+ // Create task for the worker
+ const task: ExecutorTask = {
+ taskId: uuidv4(),
+ pluginId: request.pluginId,
+ compiledCode,
+ params: request.params,
+ headers: request.headers,
+ socketPath: request.socketPath,
+ httpRequestId: request.httpRequestId,
+ timeout: request.timeout ?? DEFAULT_TIMEOUT,
+ route: request.route,
+ config: request.config,
+ method: request.method,
+ query: request.query,
+ };
+
+ // Track per-plugin execution (bounded to prevent memory leak)
+ incrementBoundedMap(this.metrics.pluginExecutions, request.pluginId, MAX_METRICS_ENTRIES);
+
+ // Use task timeout to prevent permanently stuck workers
+ // This is a safety net beyond the handler-level timeout in pool-executor
+ const taskTimeout = this.options.taskTimeout;
+ let timeoutId: NodeJS.Timeout | undefined;
+
+ try {
+ const runPromise = this.pool!.run(task);
+
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(new Error(`Task timed out after ${taskTimeout}ms (worker may be stuck)`));
+ }, taskTimeout);
+ });
+
+ const result: ExecutorResult = await Promise.race([runPromise, timeoutPromise]);
+
+ // Update execution metrics
+ const executionTime = Date.now() - executionStartTime;
+ this.metrics.totalExecutions++;
+ this.metrics.lastExecutionTime = Date.now();
+ this.metrics.totalExecutionTime += executionTime;
+ this.metrics.minExecutionTime = Math.min(this.metrics.minExecutionTime, executionTime);
+ this.metrics.maxExecutionTime = Math.max(this.metrics.maxExecutionTime, executionTime);
+
+ if (result.success) {
+ this.metrics.successfulExecutions++;
+ } else {
+ this.metrics.failedExecutions++;
+ if (result.error?.code) {
+ incrementBoundedMap(this.metrics.errorsByType, result.error.code, MAX_METRICS_ENTRIES);
+ }
+ }
+
+ return {
+ success: result.success,
+ result: result.result,
+ error: result.error,
+ logs: result.logs,
+ };
+ } catch (error) {
+ const err = error as Error;
+
+ // Update execution metrics for error case
+ const executionTime = Date.now() - executionStartTime;
+ this.metrics.totalExecutions++;
+ this.metrics.failedExecutions++;
+ this.metrics.lastExecutionTime = Date.now();
+ this.metrics.totalExecutionTime += executionTime;
+ this.metrics.minExecutionTime = Math.min(this.metrics.minExecutionTime, executionTime);
+ this.metrics.maxExecutionTime = Math.max(this.metrics.maxExecutionTime, executionTime);
+ incrementBoundedMap(this.metrics.errorsByType, 'WORKER_ERROR', MAX_METRICS_ENTRIES);
+
+ return {
+ success: false,
+ error: {
+ message: err.message || String(error),
+ code: 'WORKER_ERROR',
+ status: 500,
+ },
+ logs: [],
+ };
+ } finally {
+ // Clear timeout to prevent timer leak
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
+
+ /**
+ * Clear the compiled code cache.
+ */
+ clearCache(): void {
+ this.compiledCache.clear();
+ }
+
+ /**
+ * Invalidate a specific plugin from the cache.
+ * Removes entries by both pluginId and any associated path.
+ *
+ * @param pluginId - Plugin identifier to invalidate
+ * @param pluginPath - Optional path to also invalidate
+ */
+ invalidatePlugin(pluginId: string, pluginPath?: string): void {
+ this.compiledCache.delete(pluginId);
+ if (pluginPath) {
+ this.compiledCache.delete(pluginPath);
+ }
+ }
+
+ /**
+ * Get pool statistics including execution metrics.
+ */
+ getStats(): {
+ pool: {
+ completed: number;
+ queued: number;
+ runTime: { average: number; max: number; min: number };
+ waitTime: { average: number; max: number; min: number };
+ } | null;
+ execution: {
+ total: number;
+ successful: number;
+ failed: number;
+ successRate: number;
+ avgExecutionTime: number;
+ minExecutionTime: number;
+ maxExecutionTime: number;
+ };
+ cache: {
+ hits: number;
+ misses: number;
+ hitRate: number;
+ };
+ compilation: {
+ total: number;
+ totalTime: number;
+ avgTime: number;
+ };
+ plugins: Record;
+ errors: Record;
+ uptime: number;
+ } {
+ const poolStats = this.pool ? {
+ completed: this.pool.completed,
+ queued: this.pool.queueSize,
+ runTime: {
+ average: this.pool.runTime.average,
+ max: this.pool.runTime.max,
+ min: this.pool.runTime.min,
+ },
+ waitTime: {
+ average: this.pool.waitTime.average,
+ max: this.pool.waitTime.max,
+ min: this.pool.waitTime.min,
+ },
+ } : null;
+
+ const totalCacheAccesses = this.metrics.cacheHits + this.metrics.cacheMisses;
+
+ return {
+ pool: poolStats,
+ execution: {
+ total: this.metrics.totalExecutions,
+ successful: this.metrics.successfulExecutions,
+ failed: this.metrics.failedExecutions,
+ successRate: this.metrics.totalExecutions > 0
+ ? this.metrics.successfulExecutions / this.metrics.totalExecutions
+ : 1,
+ avgExecutionTime: this.metrics.totalExecutions > 0
+ ? this.metrics.totalExecutionTime / this.metrics.totalExecutions
+ : 0,
+ minExecutionTime: this.metrics.minExecutionTime === Infinity
+ ? 0
+ : this.metrics.minExecutionTime,
+ maxExecutionTime: this.metrics.maxExecutionTime,
+ },
+ cache: {
+ hits: this.metrics.cacheHits,
+ misses: this.metrics.cacheMisses,
+ hitRate: totalCacheAccesses > 0
+ ? this.metrics.cacheHits / totalCacheAccesses
+ : 0,
+ },
+ compilation: {
+ total: this.metrics.totalCompilations,
+ totalTime: this.metrics.totalCompilationTime,
+ avgTime: this.metrics.totalCompilations > 0
+ ? this.metrics.totalCompilationTime / this.metrics.totalCompilations
+ : 0,
+ },
+ plugins: Object.fromEntries(this.metrics.pluginExecutions),
+ errors: Object.fromEntries(this.metrics.errorsByType),
+ uptime: Date.now() - this.metrics.startTime,
+ };
+ }
+
+ /**
+ * Reset metrics to initial state.
+ * Useful for testing or periodic resets.
+ */
+ resetMetrics(): void {
+ this.metrics = createInitialMetrics();
+ }
+
+ /**
+ * Shutdown the worker pool.
+ * Call this when the application is shutting down.
+ */
+ async shutdown(): Promise {
+ if (this.pool) {
+ // Remove event listeners before destroying to prevent accumulation
+ if (this.poolErrorHandler) {
+ this.pool.off('error', this.poolErrorHandler);
+ this.poolErrorHandler = null;
+ }
+ if (this.poolExitHandler) {
+ this.pool.off('workerExit', this.poolExitHandler);
+ this.poolExitHandler = null;
+ }
+
+ await this.pool.destroy();
+ this.pool = null;
+ this.initialized = false;
+ }
+
+ // Remove process cleanup handlers to prevent accumulation
+ this.removeCleanupHandlers();
+
+ // Clean up temporary compiled worker file to prevent disk space leak
+ if (this.isTemporaryWorkerFile && this.compiledWorkerPath) {
+ try {
+ fs.unlinkSync(this.compiledWorkerPath);
+ } catch {
+ // Ignore errors - file may already be deleted or inaccessible
+ }
+ }
+
+ this.compiledWorkerPath = null;
+ this.isTemporaryWorkerFile = false;
+ // Properly destroy cache (clears timer + entries)
+ this.compiledCache.destroy();
+ }
+}
+
+// Singleton instance for convenience
+let defaultPool: WorkerPoolManager | null = null;
+
+/**
+ * Get or create the default worker pool instance.
+ */
+export function getDefaultPool(options?: WorkerPoolOptions): WorkerPoolManager {
+ if (!defaultPool) {
+ defaultPool = new WorkerPoolManager(options);
+ }
+ return defaultPool;
+}
+
+/**
+ * Shutdown the default worker pool.
+ */
+export async function shutdownDefaultPool(): Promise {
+ if (defaultPool) {
+ await defaultPool.shutdown();
+ defaultPool = null;
+ }
+}
diff --git a/plugins/package.json b/plugins/package.json
index 55944d813..e9f7e0816 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -7,16 +7,19 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
- "build": "tsc"
+ "build": "ts-node lib/build-executor.ts",
+ "postinstall": "pnpm run build"
},
"keywords": [],
"author": "",
"license": "",
"dependencies": {
- "@openzeppelin/relayer-sdk": "^1.7.0",
+ "@openzeppelin/relayer-sdk": "^1.9.0",
"@types/node": "^24.0.3",
+ "esbuild": "^0.24.0",
"ethers": "^6.14.3",
"ioredis": "^5.7.0",
+ "piscina": "^4.7.0",
"uuid": "^11.1.0"
},
"devDependencies": {
@@ -27,6 +30,7 @@
"ioredis-mock": "^8.9.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
- "typescript": "^5.3.3"
+ "typescript": "^5.3.3",
+ "ts-node": "^10.9.2"
}
}
diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml
index 7fc814cb6..e50c9b435 100644
--- a/plugins/pnpm-lock.yaml
+++ b/plugins/pnpm-lock.yaml
@@ -7,17 +7,23 @@ importers:
.:
dependencies:
'@openzeppelin/relayer-sdk':
- specifier: ^1.7.0
- version: 1.7.0
+ specifier: ^1.9.0
+ version: 1.9.0
'@types/node':
specifier: ^24.0.3
version: 24.9.2
+ esbuild:
+ specifier: ^0.24.0
+ version: 0.24.2
ethers:
specifier: ^6.14.3
version: 6.15.0
ioredis:
specifier: ^5.7.0
version: 5.8.2
+ piscina:
+ specifier: ^4.7.0
+ version: 4.9.2
uuid:
specifier: ^11.1.0
version: 11.1.0
@@ -39,10 +45,13 @@ importers:
version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.8.2))(ioredis@5.8.2)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@24.9.2)
+ version: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.1.2
- version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2))(typescript@5.9.3)
+ version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.24.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)))(typescript@5.9.3)
+ ts-node:
+ specifier: ^10.9.2
+ version: 10.9.2(@types/node@24.9.2)(typescript@5.9.3)
typescript:
specifier: ^5.3.3
version: 5.9.3
@@ -179,6 +188,184 @@ packages:
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@cspotcode/source-map-support@0.8.1':
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
+ '@esbuild/aix-ppc64@0.24.2':
+ resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
+ engines: {node: '>=18'}
+ cpu:
+ - ppc64
+ os:
+ - aix
+ '@esbuild/android-arm64@0.24.2':
+ resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - android
+ '@esbuild/android-arm@0.24.2':
+ resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm
+ os:
+ - android
+ '@esbuild/android-x64@0.24.2':
+ resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - android
+ '@esbuild/darwin-arm64@0.24.2':
+ resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - darwin
+ '@esbuild/darwin-x64@0.24.2':
+ resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - darwin
+ '@esbuild/freebsd-arm64@0.24.2':
+ resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - freebsd
+ '@esbuild/freebsd-x64@0.24.2':
+ resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - freebsd
+ '@esbuild/linux-arm64@0.24.2':
+ resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - linux
+ '@esbuild/linux-arm@0.24.2':
+ resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm
+ os:
+ - linux
+ '@esbuild/linux-ia32@0.24.2':
+ resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
+ engines: {node: '>=18'}
+ cpu:
+ - ia32
+ os:
+ - linux
+ '@esbuild/linux-loong64@0.24.2':
+ resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
+ engines: {node: '>=18'}
+ cpu:
+ - loong64
+ os:
+ - linux
+ '@esbuild/linux-mips64el@0.24.2':
+ resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
+ engines: {node: '>=18'}
+ cpu:
+ - mips64el
+ os:
+ - linux
+ '@esbuild/linux-ppc64@0.24.2':
+ resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
+ engines: {node: '>=18'}
+ cpu:
+ - ppc64
+ os:
+ - linux
+ '@esbuild/linux-riscv64@0.24.2':
+ resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
+ engines: {node: '>=18'}
+ cpu:
+ - riscv64
+ os:
+ - linux
+ '@esbuild/linux-s390x@0.24.2':
+ resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
+ engines: {node: '>=18'}
+ cpu:
+ - s390x
+ os:
+ - linux
+ '@esbuild/linux-x64@0.24.2':
+ resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - linux
+ '@esbuild/netbsd-arm64@0.24.2':
+ resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - netbsd
+ '@esbuild/netbsd-x64@0.24.2':
+ resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - netbsd
+ '@esbuild/openbsd-arm64@0.24.2':
+ resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - openbsd
+ '@esbuild/openbsd-x64@0.24.2':
+ resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - openbsd
+ '@esbuild/sunos-x64@0.24.2':
+ resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - sunos
+ '@esbuild/win32-arm64@0.24.2':
+ resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
+ engines: {node: '>=18'}
+ cpu:
+ - arm64
+ os:
+ - win32
+ '@esbuild/win32-ia32@0.24.2':
+ resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
+ engines: {node: '>=18'}
+ cpu:
+ - ia32
+ os:
+ - win32
+ '@esbuild/win32-x64@0.24.2':
+ resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
+ engines: {node: '>=18'}
+ cpu:
+ - x64
+ os:
+ - win32
'@ioredis/as-callback@3.0.0':
resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==}
'@ioredis/commands@1.4.0':
@@ -252,13 +439,137 @@ packages:
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@jridgewell/trace-mapping@0.3.9':
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@napi-rs/nice-android-arm-eabi@1.1.1':
+ resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm
+ os:
+ - android
+ '@napi-rs/nice-android-arm64@1.1.1':
+ resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - android
+ '@napi-rs/nice-darwin-arm64@1.1.1':
+ resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - darwin
+ '@napi-rs/nice-darwin-x64@1.1.1':
+ resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - x64
+ os:
+ - darwin
+ '@napi-rs/nice-freebsd-x64@1.1.1':
+ resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - x64
+ os:
+ - freebsd
+ '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+ resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm
+ os:
+ - linux
+ '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+ resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - linux
+ '@napi-rs/nice-linux-arm64-musl@1.1.1':
+ resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - linux
+ '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+ resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
+ engines: {node: '>= 10'}
+ cpu:
+ - ppc64
+ os:
+ - linux
+ '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+ resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
+ engines: {node: '>= 10'}
+ cpu:
+ - riscv64
+ os:
+ - linux
+ '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+ resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - s390x
+ os:
+ - linux
+ '@napi-rs/nice-linux-x64-gnu@1.1.1':
+ resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
+ engines: {node: '>= 10'}
+ cpu:
+ - x64
+ os:
+ - linux
+ '@napi-rs/nice-linux-x64-musl@1.1.1':
+ resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
+ engines: {node: '>= 10'}
+ cpu:
+ - x64
+ os:
+ - linux
+ '@napi-rs/nice-openharmony-arm64@1.1.1':
+ resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - openharmony
+ '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+ resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==}
+ engines: {node: '>= 10'}
+ cpu:
+ - arm64
+ os:
+ - win32
+ '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+ resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==}
+ engines: {node: '>= 10'}
+ cpu:
+ - ia32
+ os:
+ - win32
+ '@napi-rs/nice-win32-x64-msvc@1.1.1':
+ resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==}
+ engines: {node: '>= 10'}
+ cpu:
+ - x64
+ os:
+ - win32
+ '@napi-rs/nice@1.1.1':
+ resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==}
+ engines: {node: '>= 10'}
'@noble/curves@1.2.0':
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
'@noble/hashes@1.3.2':
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
- '@openzeppelin/relayer-sdk@1.7.0':
- resolution: {integrity: sha512-XaQDdfooj6LC9Xa2fhq0ElaF0q/+rn4D0KNKDiwUa+QdZk18LeIiKxyZKxXuA4RynXxosISe1oQW78XYC/nE6Q==}
+ '@openzeppelin/relayer-sdk@1.9.0':
+ resolution: {integrity: sha512-G3Zhg0imaT9Ej1cE9Cq5d4uQvW+DinD2GvRuiPWo3+O9KI8ivBnGJkjEGgK9Ja3/QsUNIfx8Gk5Mdw2sPXjVWA==}
engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -266,6 +577,14 @@ packages:
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
+ '@tsconfig/node10@1.0.12':
+ resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
+ '@tsconfig/node12@1.0.11':
+ resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
+ '@tsconfig/node14@1.0.3':
+ resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
+ '@tsconfig/node16@1.0.4':
+ resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
'@types/babel__generator@7.27.0':
@@ -300,6 +619,13 @@ packages:
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
'@types/yargs@17.0.34':
resolution: {integrity: sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==}
+ acorn-walk@8.3.4:
+ resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
+ engines: {node: '>=0.4.0'}
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
aes-js@4.0.0-beta.5:
resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
ansi-escapes@4.3.2:
@@ -317,6 +643,8 @@ packages:
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ arg@4.1.3:
+ resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
asynckit@0.4.0:
@@ -416,6 +744,8 @@ packages:
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
+ create-require@1.1.1:
+ resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -449,6 +779,9 @@ packages:
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ diff@4.0.2:
+ resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+ engines: {node: '>=0.3.1'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -473,6 +806,10 @@ packages:
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
+ esbuild@0.24.2:
+ resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
+ engines: {node: '>=18'}
+ hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -863,6 +1200,8 @@ packages:
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
+ piscina@4.9.2:
+ resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==}
pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
@@ -1001,6 +1340,19 @@ packages:
optional: true
jest-util:
optional: true
+ ts-node@10.9.2:
+ resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
+ hasBin: true
+ peerDependencies:
+ '@swc/core': '>=1.2.50'
+ '@swc/wasm': '>=1.2.50'
+ '@types/node': '*'
+ typescript: '>=2.7'
+ peerDependenciesMeta:
+ '@swc/core':
+ optional: true
+ '@swc/wasm':
+ optional: true
tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
type-detect@4.0.8:
@@ -1032,6 +1384,8 @@ packages:
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
+ v8-compile-cache-lib@3.0.1:
+ resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
v8-to-istanbul@9.3.0:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
@@ -1073,6 +1427,9 @@ packages:
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yn@3.1.1:
+ resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+ engines: {node: '>=6'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1232,6 +1589,59 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@0.2.3': {}
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+ '@esbuild/aix-ppc64@0.24.2':
+ optional: true
+ '@esbuild/android-arm64@0.24.2':
+ optional: true
+ '@esbuild/android-arm@0.24.2':
+ optional: true
+ '@esbuild/android-x64@0.24.2':
+ optional: true
+ '@esbuild/darwin-arm64@0.24.2':
+ optional: true
+ '@esbuild/darwin-x64@0.24.2':
+ optional: true
+ '@esbuild/freebsd-arm64@0.24.2':
+ optional: true
+ '@esbuild/freebsd-x64@0.24.2':
+ optional: true
+ '@esbuild/linux-arm64@0.24.2':
+ optional: true
+ '@esbuild/linux-arm@0.24.2':
+ optional: true
+ '@esbuild/linux-ia32@0.24.2':
+ optional: true
+ '@esbuild/linux-loong64@0.24.2':
+ optional: true
+ '@esbuild/linux-mips64el@0.24.2':
+ optional: true
+ '@esbuild/linux-ppc64@0.24.2':
+ optional: true
+ '@esbuild/linux-riscv64@0.24.2':
+ optional: true
+ '@esbuild/linux-s390x@0.24.2':
+ optional: true
+ '@esbuild/linux-x64@0.24.2':
+ optional: true
+ '@esbuild/netbsd-arm64@0.24.2':
+ optional: true
+ '@esbuild/netbsd-x64@0.24.2':
+ optional: true
+ '@esbuild/openbsd-arm64@0.24.2':
+ optional: true
+ '@esbuild/openbsd-x64@0.24.2':
+ optional: true
+ '@esbuild/sunos-x64@0.24.2':
+ optional: true
+ '@esbuild/win32-arm64@0.24.2':
+ optional: true
+ '@esbuild/win32-ia32@0.24.2':
+ optional: true
+ '@esbuild/win32-x64@0.24.2':
+ optional: true
'@ioredis/as-callback@3.0.0': {}
'@ioredis/commands@1.4.0': {}
'@istanbuljs/load-nyc-config@1.1.0':
@@ -1250,7 +1660,7 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
slash: 3.0.0
- '@jest/core@29.7.0':
+ '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
@@ -1264,7 +1674,7 @@ snapshots:
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@24.9.2)
+ jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -1404,11 +1814,69 @@ snapshots:
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping@0.3.9':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@napi-rs/nice-android-arm-eabi@1.1.1':
+ optional: true
+ '@napi-rs/nice-android-arm64@1.1.1':
+ optional: true
+ '@napi-rs/nice-darwin-arm64@1.1.1':
+ optional: true
+ '@napi-rs/nice-darwin-x64@1.1.1':
+ optional: true
+ '@napi-rs/nice-freebsd-x64@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-arm64-musl@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-x64-gnu@1.1.1':
+ optional: true
+ '@napi-rs/nice-linux-x64-musl@1.1.1':
+ optional: true
+ '@napi-rs/nice-openharmony-arm64@1.1.1':
+ optional: true
+ '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+ optional: true
+ '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+ optional: true
+ '@napi-rs/nice-win32-x64-msvc@1.1.1':
+ optional: true
+ '@napi-rs/nice@1.1.1':
+ optionalDependencies:
+ '@napi-rs/nice-android-arm-eabi': 1.1.1
+ '@napi-rs/nice-android-arm64': 1.1.1
+ '@napi-rs/nice-darwin-arm64': 1.1.1
+ '@napi-rs/nice-darwin-x64': 1.1.1
+ '@napi-rs/nice-freebsd-x64': 1.1.1
+ '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1
+ '@napi-rs/nice-linux-arm64-gnu': 1.1.1
+ '@napi-rs/nice-linux-arm64-musl': 1.1.1
+ '@napi-rs/nice-linux-ppc64-gnu': 1.1.1
+ '@napi-rs/nice-linux-riscv64-gnu': 1.1.1
+ '@napi-rs/nice-linux-s390x-gnu': 1.1.1
+ '@napi-rs/nice-linux-x64-gnu': 1.1.1
+ '@napi-rs/nice-linux-x64-musl': 1.1.1
+ '@napi-rs/nice-openharmony-arm64': 1.1.1
+ '@napi-rs/nice-win32-arm64-msvc': 1.1.1
+ '@napi-rs/nice-win32-ia32-msvc': 1.1.1
+ '@napi-rs/nice-win32-x64-msvc': 1.1.1
+ optional: true
'@noble/curves@1.2.0':
dependencies:
'@noble/hashes': 1.3.2
'@noble/hashes@1.3.2': {}
- '@openzeppelin/relayer-sdk@1.7.0':
+ '@openzeppelin/relayer-sdk@1.9.0':
dependencies:
axios: 1.13.1
transitivePeerDependencies:
@@ -1420,6 +1888,10 @@ snapshots:
'@sinonjs/fake-timers@10.3.0':
dependencies:
'@sinonjs/commons': 3.0.1
+ '@tsconfig/node10@1.0.12': {}
+ '@tsconfig/node12@1.0.11': {}
+ '@tsconfig/node14@1.0.3': {}
+ '@tsconfig/node16@1.0.4': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.5
@@ -1466,6 +1938,10 @@ snapshots:
'@types/yargs@17.0.34':
dependencies:
'@types/yargs-parser': 21.0.3
+ acorn-walk@8.3.4:
+ dependencies:
+ acorn: 8.15.0
+ acorn@8.15.0: {}
aes-js@4.0.0-beta.5: {}
ansi-escapes@4.3.2:
dependencies:
@@ -1479,6 +1955,7 @@ snapshots:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
+ arg@4.1.3: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
@@ -1595,13 +2072,13 @@ snapshots:
delayed-stream: 1.0.0
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
- create-jest@29.7.0(@types/node@24.9.2):
+ create-jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@24.9.2)
+ jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -1609,6 +2086,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- ts-node
+ create-require@1.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -1623,6 +2101,7 @@ snapshots:
denque@2.1.0: {}
detect-newline@3.1.0: {}
diff-sequences@29.6.3: {}
+ diff@4.0.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1645,6 +2124,33 @@ snapshots:
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
+ esbuild@0.24.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.24.2
+ '@esbuild/android-arm': 0.24.2
+ '@esbuild/android-arm64': 0.24.2
+ '@esbuild/android-x64': 0.24.2
+ '@esbuild/darwin-arm64': 0.24.2
+ '@esbuild/darwin-x64': 0.24.2
+ '@esbuild/freebsd-arm64': 0.24.2
+ '@esbuild/freebsd-x64': 0.24.2
+ '@esbuild/linux-arm': 0.24.2
+ '@esbuild/linux-arm64': 0.24.2
+ '@esbuild/linux-ia32': 0.24.2
+ '@esbuild/linux-loong64': 0.24.2
+ '@esbuild/linux-mips64el': 0.24.2
+ '@esbuild/linux-ppc64': 0.24.2
+ '@esbuild/linux-riscv64': 0.24.2
+ '@esbuild/linux-s390x': 0.24.2
+ '@esbuild/linux-x64': 0.24.2
+ '@esbuild/netbsd-arm64': 0.24.2
+ '@esbuild/netbsd-x64': 0.24.2
+ '@esbuild/openbsd-arm64': 0.24.2
+ '@esbuild/openbsd-x64': 0.24.2
+ '@esbuild/sunos-x64': 0.24.2
+ '@esbuild/win32-arm64': 0.24.2
+ '@esbuild/win32-ia32': 0.24.2
+ '@esbuild/win32-x64': 0.24.2
escalade@3.2.0: {}
escape-string-regexp@2.0.0: {}
esprima@4.0.1: {}
@@ -1865,16 +2371,16 @@ snapshots:
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0(@types/node@24.9.2):
+ jest-cli@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@24.9.2)
+ create-jest: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@24.9.2)
+ jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -1883,7 +2389,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- ts-node
- jest-config@29.7.0(@types/node@24.9.2):
+ jest-config@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
@@ -1909,6 +2415,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 24.9.2
+ ts-node: 10.9.2(@types/node@24.9.2)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -2106,12 +2613,12 @@ snapshots:
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@24.9.2):
+ jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@24.9.2)
+ jest-cli: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -2198,6 +2705,9 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.1: {}
pirates@4.0.7: {}
+ piscina@4.9.2:
+ optionalDependencies:
+ '@napi-rs/nice': 1.1.1
pkg-dir@4.2.0:
dependencies:
find-up: 4.1.0
@@ -2283,12 +2793,12 @@ snapshots:
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
- ? ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2))(typescript@5.9.3)
+ ? ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.24.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)))(typescript@5.9.3)
: dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@24.9.2)
+ jest: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -2301,7 +2811,25 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
+ esbuild: 0.24.2
jest-util: 29.7.0
+ ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 24.9.2
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.9.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
tslib@2.7.0: {}
type-detect@4.0.8: {}
type-fest@0.21.3: {}
@@ -2317,6 +2845,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uuid@11.1.0: {}
+ v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -2352,4 +2881,5 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
+ yn@3.1.1: {}
yocto-queue@0.1.0: {}
diff --git a/plugins/tests/lib/direct-executor.test.ts b/plugins/tests/lib/direct-executor.test.ts
new file mode 100644
index 000000000..7ffcc1ca8
--- /dev/null
+++ b/plugins/tests/lib/direct-executor.test.ts
@@ -0,0 +1,1324 @@
+import * as vm from 'node:vm';
+import * as v8 from 'node:v8';
+import * as net from 'node:net';
+
+describe('ContextPool logic', () => {
+ class MockContextPool {
+ private available: any[] = [];
+ private readonly maxSize = 10;
+
+ acquire(): any {
+ const ctx = this.available.pop();
+ if (ctx) {
+ this.resetContext(ctx);
+ return ctx;
+ }
+ return this.createFreshContext();
+ }
+
+ release(ctx: any): void {
+ if (this.available.length < this.maxSize) {
+ this.available.push(ctx);
+ }
+ }
+
+ private createFreshContext(): any {
+ return { _fresh: true };
+ }
+
+ private resetContext(ctx: any): void {
+ if (ctx.__pluginState) {
+ delete ctx.__pluginState;
+ }
+ }
+
+ clear(): void {
+ this.available = [];
+ }
+
+ size(): number {
+ return this.available.length;
+ }
+ }
+
+ it('should create new context when pool is empty', () => {
+ const pool = new MockContextPool();
+ const ctx = pool.acquire();
+
+ expect(ctx).toBeDefined();
+ expect(ctx._fresh).toBe(true);
+ expect(pool.size()).toBe(0);
+ });
+
+ it('should reuse context from pool', () => {
+ const pool = new MockContextPool();
+ const ctx1 = pool.acquire();
+ pool.release(ctx1);
+
+ expect(pool.size()).toBe(1);
+
+ const ctx2 = pool.acquire();
+ expect(ctx2).toBe(ctx1); // Same object
+ expect(pool.size()).toBe(0);
+ });
+
+ it('should not exceed max pool size', () => {
+ const pool = new MockContextPool();
+ const contexts: any[] = [];
+
+ // Create 15 contexts
+ for (let i = 0; i < 15; i++) {
+ const ctx = pool.acquire();
+ ctx.id = i;
+ contexts.push(ctx);
+ }
+
+ // Release them one by one
+ for (const ctx of contexts) {
+ pool.release(ctx);
+ }
+
+ expect(pool.size()).toBe(10); // Should cap at maxSize
+ });
+
+ it('should reset context state before reuse', () => {
+ const pool = new MockContextPool();
+ const ctx = pool.acquire();
+
+ // Simulate plugin pollution
+ (ctx as any).__pluginState = { dirty: true };
+ pool.release(ctx);
+
+ const reusedCtx = pool.acquire();
+ expect((reusedCtx as any).__pluginState).toBeUndefined();
+ });
+
+ it('should clear all contexts', () => {
+ const pool = new MockContextPool();
+ const contexts: any[] = [];
+
+ for (let i = 0; i < 5; i++) {
+ const ctx = pool.acquire();
+ contexts.push(ctx);
+ }
+
+ // Release them to the pool
+ for (const ctx of contexts) {
+ pool.release(ctx);
+ }
+
+ expect(pool.size()).toBe(5);
+ pool.clear();
+ expect(pool.size()).toBe(0);
+ });
+});
+
+describe('ScriptCache logic', () => {
+ class MockScriptCache {
+ private cache = new Map();
+ private readonly maxSize = 100;
+
+ get(code: string): any | undefined {
+ const entry = this.cache.get(code);
+ if (entry) {
+ entry.timestamp = Date.now();
+ return entry.script;
+ }
+ return undefined;
+ }
+
+ set(code: string, script: any): void {
+ if (this.cache.size >= this.maxSize) {
+ this.evictOldest(1);
+ }
+ this.cache.set(code, { script, timestamp: Date.now() });
+ }
+
+ evictOldest(count: number): void {
+ const entries = [...this.cache.entries()].sort(
+ (a, b) => a[1].timestamp - b[1].timestamp
+ );
+
+ let evicted = 0;
+ for (const [key] of entries) {
+ if (evicted >= count) break;
+ this.cache.delete(key);
+ evicted++;
+ }
+ }
+
+ clear(): void {
+ this.cache.clear();
+ }
+
+ size(): number {
+ return this.cache.size;
+ }
+ }
+
+ it('should cache compiled scripts', () => {
+ const cache = new MockScriptCache();
+ const code = 'console.log("test")';
+ const script = { compiled: true };
+
+ cache.set(code, script);
+ const retrieved = cache.get(code);
+
+ expect(retrieved).toBe(script);
+ });
+
+ it('should return undefined for cache miss', () => {
+ const cache = new MockScriptCache();
+ const result = cache.get('nonexistent-code');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should update timestamp on cache hit (LRU)', async () => {
+ const cache = new MockScriptCache();
+ const code = 'test';
+ const script = { compiled: true };
+
+ cache.set(code, script);
+ const timestamp1 = (cache as any).cache.get(code).timestamp;
+
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ cache.get(code);
+ const timestamp2 = (cache as any).cache.get(code).timestamp;
+ expect(timestamp2).toBeGreaterThanOrEqual(timestamp1);
+ });
+
+ it('should evict oldest entry when at capacity', () => {
+ const cache = new MockScriptCache();
+
+ // Fill cache to capacity
+ for (let i = 0; i < 100; i++) {
+ cache.set(`code-${i}`, { id: i });
+ }
+
+ expect(cache.size()).toBe(100);
+
+ // Add one more - should evict oldest
+ cache.set('code-101', { id: 101 });
+
+ expect(cache.size()).toBe(100);
+ expect(cache.get('code-0')).toBeUndefined(); // First one evicted
+ expect(cache.get('code-101')).toBeDefined(); // New one added
+ });
+
+ it('should evict multiple oldest entries', () => {
+ const cache = new MockScriptCache();
+
+ for (let i = 0; i < 10; i++) {
+ cache.set(`code-${i}`, { id: i });
+ }
+
+ cache.evictOldest(3);
+
+ expect(cache.size()).toBe(7);
+ expect(cache.get('code-0')).toBeUndefined();
+ expect(cache.get('code-1')).toBeUndefined();
+ expect(cache.get('code-2')).toBeUndefined();
+ expect(cache.get('code-3')).toBeDefined();
+ });
+
+ it('should clear entire cache', () => {
+ const cache = new MockScriptCache();
+
+ for (let i = 0; i < 10; i++) {
+ cache.set(`code-${i}`, { id: i });
+ }
+
+ expect(cache.size()).toBe(10);
+ cache.clear();
+ expect(cache.size()).toBe(0);
+ });
+
+ it('should handle memory pressure by evicting cache', () => {
+ const heapLimit = 1000 * 1024 * 1024; // 1000MB
+ const heapUsed = 700 * 1024 * 1024; // 700MB (70%)
+
+ const heapUsedRatio = heapUsed / heapLimit;
+
+ const cache = new MockScriptCache();
+ for (let i = 0; i < 100; i++) {
+ cache.set(`code-${i}`, { id: i });
+ }
+
+ // Simulate eviction at 70% threshold
+ if (heapUsedRatio >= 0.70 && cache.size() > 0) {
+ const evictCount = Math.max(1, Math.ceil(cache.size() * 0.25));
+ cache.evictOldest(evictCount);
+ }
+
+ expect(cache.size()).toBeLessThan(100);
+ expect(cache.size()).toBeGreaterThanOrEqual(75); // 25% evicted
+ });
+});
+
+describe('SocketPool logic', () => {
+ class MockSocketPool {
+ private available: any[] = [];
+ private readonly maxSize = 5;
+
+ acquire(socketPath: string): any | null {
+ const socket = this.available.pop();
+ if (socket && socket.writable && !socket.destroyed) {
+ return socket;
+ }
+ return null;
+ }
+
+ release(socket: any): void {
+ if (socket.writable && !socket.destroyed && this.available.length < this.maxSize) {
+ this.available.push(socket);
+ } else if (socket.destroy) {
+ socket.destroy();
+ }
+ }
+
+ clear(): void {
+ for (const socket of this.available) {
+ if (socket.destroy) socket.destroy();
+ }
+ this.available = [];
+ }
+
+ size(): number {
+ return this.available.length;
+ }
+ }
+
+ it('should return null when pool is empty', () => {
+ const pool = new MockSocketPool();
+ const socket = pool.acquire('/tmp/test.sock');
+
+ expect(socket).toBeNull();
+ });
+
+ it('should reuse healthy socket from pool', () => {
+ const pool = new MockSocketPool();
+ const mockSocket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(mockSocket);
+ expect(pool.size()).toBe(1);
+
+ const reused = pool.acquire('/tmp/test.sock');
+ expect(reused).toBe(mockSocket);
+ expect(pool.size()).toBe(0);
+ });
+
+ it('should destroy unhealthy socket instead of pooling', () => {
+ const pool = new MockSocketPool();
+ const mockSocket = {
+ writable: false,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(mockSocket);
+
+ expect(mockSocket.destroy).toHaveBeenCalled();
+ expect(pool.size()).toBe(0);
+ });
+
+ it('should not exceed max pool size', () => {
+ const pool = new MockSocketPool();
+
+ for (let i = 0; i < 10; i++) {
+ pool.release({
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ });
+ }
+
+ expect(pool.size()).toBe(5); // Max size is 5
+ });
+
+ it('should destroy all sockets on clear', () => {
+ const pool = new MockSocketPool();
+ const sockets = [];
+
+ for (let i = 0; i < 5; i++) {
+ const socket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+ sockets.push(socket);
+ pool.release(socket);
+ }
+
+ pool.clear();
+
+ expect(pool.size()).toBe(0);
+ for (const socket of sockets) {
+ expect(socket.destroy).toHaveBeenCalled();
+ }
+ });
+
+ it('should skip destroyed sockets during reuse', () => {
+ const pool = new MockSocketPool();
+ const mockSocket = {
+ writable: true,
+ destroyed: true, // Already destroyed
+ destroy: jest.fn(),
+ };
+
+ pool.release(mockSocket);
+ const reused = pool.acquire('/tmp/test.sock');
+
+ expect(reused).toBeNull(); // Should not return destroyed socket
+ });
+});
+
+describe('SocketClosedError', () => {
+ // Replicate the error class for testing
+ class SocketClosedError extends Error {
+ code: string;
+ constructor(message: string) {
+ super(message);
+ this.name = 'SocketClosedError';
+ this.code = 'ESOCKETCLOSED';
+ }
+ }
+
+ it('should have correct name', () => {
+ const error = new SocketClosedError('test message');
+ expect(error.name).toBe('SocketClosedError');
+ });
+
+ it('should have ESOCKETCLOSED code', () => {
+ const error = new SocketClosedError('test message');
+ expect(error.code).toBe('ESOCKETCLOSED');
+ });
+
+ it('should preserve message', () => {
+ const error = new SocketClosedError('Connection lifetime exceeded');
+ expect(error.message).toBe('Connection lifetime exceeded');
+ });
+
+ it('should be instanceof Error', () => {
+ const error = new SocketClosedError('test');
+ expect(error).toBeInstanceOf(Error);
+ });
+
+ it('should have stack trace', () => {
+ const error = new SocketClosedError('test');
+ expect(error.stack).toBeDefined();
+ expect(error.stack).toContain('SocketClosedError');
+ });
+});
+
+describe('Retry logic for connection errors', () => {
+ // Simulates the retry logic in sendWithRetry
+ function isRetryableError(error: any): boolean {
+ return (
+ error.code === 'EPIPE' ||
+ error.code === 'ECONNRESET' ||
+ error.code === 'ESOCKETCLOSED'
+ );
+ }
+
+ it('should identify EPIPE as retryable', () => {
+ const error: any = new Error('broken pipe');
+ error.code = 'EPIPE';
+ expect(isRetryableError(error)).toBe(true);
+ });
+
+ it('should identify ECONNRESET as retryable', () => {
+ const error: any = new Error('connection reset');
+ error.code = 'ECONNRESET';
+ expect(isRetryableError(error)).toBe(true);
+ });
+
+ it('should identify ESOCKETCLOSED as retryable', () => {
+ const error: any = new Error('socket closed');
+ error.code = 'ESOCKETCLOSED';
+ expect(isRetryableError(error)).toBe(true);
+ });
+
+ it('should not retry generic errors', () => {
+ const error = new Error('generic error');
+ expect(isRetryableError(error)).toBe(false);
+ });
+
+ it('should not retry timeout errors', () => {
+ const error: any = new Error('timeout');
+ error.code = 'ETIMEDOUT';
+ expect(isRetryableError(error)).toBe(false);
+ });
+
+ it('should not retry errors without code', () => {
+ const error = new Error('no code');
+ expect(isRetryableError(error)).toBe(false);
+ });
+});
+
+describe('Age-based socket eviction', () => {
+ const MAX_SOCKET_AGE_MS = 50_000; // 50 seconds, matching production code
+
+ interface PooledSocket {
+ socket: { writable: boolean; destroyed: boolean; destroy: () => void };
+ createdAt: number;
+ }
+
+ function shouldEvictSocket(pooled: PooledSocket, now: number): boolean {
+ const age = now - pooled.createdAt;
+ return age > MAX_SOCKET_AGE_MS;
+ }
+
+ it('should not evict fresh socket', () => {
+ const now = Date.now();
+ const pooled: PooledSocket = {
+ socket: { writable: true, destroyed: false, destroy: jest.fn() },
+ createdAt: now - 10_000, // 10 seconds old
+ };
+ expect(shouldEvictSocket(pooled, now)).toBe(false);
+ });
+
+ it('should evict socket older than 50 seconds', () => {
+ const now = Date.now();
+ const pooled: PooledSocket = {
+ socket: { writable: true, destroyed: false, destroy: jest.fn() },
+ createdAt: now - 51_000, // 51 seconds old
+ };
+ expect(shouldEvictSocket(pooled, now)).toBe(true);
+ });
+
+ it('should not evict socket exactly at 50 seconds', () => {
+ const now = Date.now();
+ const pooled: PooledSocket = {
+ socket: { writable: true, destroyed: false, destroy: jest.fn() },
+ createdAt: now - 50_000, // exactly 50 seconds
+ };
+ expect(shouldEvictSocket(pooled, now)).toBe(false);
+ });
+
+ it('should evict socket at 55 seconds (within danger zone)', () => {
+ const now = Date.now();
+ const pooled: PooledSocket = {
+ socket: { writable: true, destroyed: false, destroy: jest.fn() },
+ createdAt: now - 55_000, // 55 seconds old
+ };
+ expect(shouldEvictSocket(pooled, now)).toBe(true);
+ });
+
+ it('should provide 10 second safety margin before Rust 60s timeout', () => {
+ // Rust kills connections at 60s, we evict at 50s = 10s safety margin
+ const RUST_TIMEOUT = 60_000;
+ const SAFETY_MARGIN = RUST_TIMEOUT - MAX_SOCKET_AGE_MS;
+ expect(SAFETY_MARGIN).toBe(10_000);
+ });
+});
+
+describe('Socket pool with age tracking', () => {
+ class MockSocketPoolWithAge {
+ private available: { socket: any; createdAt: number }[] = [];
+ private readonly maxSize = 5;
+ private readonly maxSocketAgeMs = 50_000;
+
+ acquire(): { socket: any; createdAt: number } | null {
+ const now = Date.now();
+
+ while (this.available.length > 0) {
+ const pooled = this.available.pop()!;
+ const age = now - pooled.createdAt;
+
+ // Discard old sockets
+ if (age > this.maxSocketAgeMs) {
+ pooled.socket.destroy();
+ continue;
+ }
+
+ if (pooled.socket.writable && !pooled.socket.destroyed) {
+ return pooled;
+ }
+
+ pooled.socket.destroy();
+ }
+
+ return null;
+ }
+
+ release(socket: any, createdAt: number): void {
+ const now = Date.now();
+ const age = now - createdAt;
+
+ if (
+ age > this.maxSocketAgeMs ||
+ !socket.writable ||
+ socket.destroyed ||
+ this.available.length >= this.maxSize
+ ) {
+ socket.destroy();
+ return;
+ }
+
+ this.available.push({ socket, createdAt });
+ }
+
+ size(): number {
+ return this.available.length;
+ }
+ }
+
+ it('should reject old socket on acquire', () => {
+ const pool = new MockSocketPoolWithAge();
+ const oldCreatedAt = Date.now() - 55_000; // 55 seconds ago
+
+ const oldSocket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(oldSocket, oldCreatedAt);
+
+ // Simulate time passing
+ jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 1);
+
+ const acquired = pool.acquire();
+ expect(acquired).toBeNull();
+ expect(oldSocket.destroy).toHaveBeenCalled();
+
+ jest.restoreAllMocks();
+ });
+
+ it('should accept fresh socket on acquire', () => {
+ const pool = new MockSocketPoolWithAge();
+ const freshCreatedAt = Date.now() - 10_000; // 10 seconds ago
+
+ const freshSocket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(freshSocket, freshCreatedAt);
+ const acquired = pool.acquire();
+
+ expect(acquired).not.toBeNull();
+ expect(acquired?.socket).toBe(freshSocket);
+ expect(acquired?.createdAt).toBe(freshCreatedAt);
+ });
+
+ it('should not pool socket that is too old on release', () => {
+ const pool = new MockSocketPoolWithAge();
+ const oldCreatedAt = Date.now() - 55_000;
+
+ const socket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(socket, oldCreatedAt);
+
+ expect(pool.size()).toBe(0);
+ expect(socket.destroy).toHaveBeenCalled();
+ });
+
+ it('should track createdAt through acquire/release cycle', () => {
+ const pool = new MockSocketPoolWithAge();
+ const originalCreatedAt = Date.now() - 20_000; // 20 seconds ago
+
+ const socket = {
+ writable: true,
+ destroyed: false,
+ destroy: jest.fn(),
+ };
+
+ pool.release(socket, originalCreatedAt);
+ const acquired = pool.acquire();
+
+ // createdAt should be preserved (not reset)
+ expect(acquired?.createdAt).toBe(originalCreatedAt);
+ });
+});
+
+describe('PluginAPIImpl socket handling', () => {
+ class MockPluginAPIImpl {
+ private socket: any = null;
+ private pending = new Map();
+ private connected = false;
+ private connectionPromise: Promise | null = null;
+ private socketPath: string;
+ private socketCreatedAt: number = 0;
+ private readonly maxPendingRequests = 100;
+
+ constructor(socketPath: string) {
+ this.socketPath = socketPath;
+ }
+
+ async ensureConnected(): Promise {
+ if (this.connected) return;
+ if (!this.connectionPromise) {
+ this.connectionPromise = this.connect();
+ }
+ await this.connectionPromise;
+ }
+
+ private async connect(): Promise {
+ // Simulate connection
+ this.socket = { writable: true, destroyed: false };
+ this.socketCreatedAt = Date.now();
+ this.connected = true;
+ }
+
+ handleSocketError(error: Error): void {
+ this.connected = false;
+ this.connectionPromise = null;
+ this.socket = null;
+ this.rejectAllPending(error);
+ }
+
+ handleSocketClose(): void {
+ this.connected = false;
+ this.connectionPromise = null;
+ this.socket = null;
+ // Use SocketClosedError-like error
+ const error: any = new Error('Socket closed by server (connection lifetime exceeded)');
+ error.code = 'ESOCKETCLOSED';
+ error.name = 'SocketClosedError';
+ this.rejectAllPending(error);
+ }
+
+ private rejectAllPending(error: Error): void {
+ for (const [requestId, resolver] of this.pending.entries()) {
+ resolver.reject(error);
+ this.pending.delete(requestId);
+ }
+ }
+
+ async send(method: string, payload: any): Promise {
+ if (this.pending.size >= this.maxPendingRequests) {
+ throw new Error(
+ `Too many concurrent API requests (max ${this.maxPendingRequests})`
+ );
+ }
+
+ await this.ensureConnected();
+
+ return new Promise((resolve, reject) => {
+ const requestId = 'test-id';
+ this.pending.set(requestId, { resolve, reject });
+
+ // Simulate response
+ setTimeout(() => {
+ const resolver = this.pending.get(requestId);
+ if (resolver) {
+ resolver.resolve({ success: true });
+ this.pending.delete(requestId);
+ }
+ }, 10);
+ });
+ }
+
+ getPendingCount(): number {
+ return this.pending.size;
+ }
+
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ getSocketCreatedAt(): number {
+ return this.socketCreatedAt;
+ }
+ }
+
+ it('should establish connection on first request', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ expect(api.isConnected()).toBe(false);
+
+ await api.send('testMethod', {});
+
+ expect(api.isConnected()).toBe(true);
+ });
+
+ it('should reuse existing connection', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ await api.send('method1', {});
+ const wasConnected = api.isConnected();
+
+ await api.send('method2', {});
+ expect(api.isConnected()).toBe(wasConnected);
+ });
+
+ it('should handle socket error and reject pending requests', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ await api.send('method1', {});
+
+ const error = new Error('Socket error');
+ api.handleSocketError(error);
+
+ expect(api.isConnected()).toBe(false);
+ expect(api.getPendingCount()).toBe(0);
+ });
+
+ it('should handle socket close and reset connection', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ await api.send('method1', {});
+ expect(api.isConnected()).toBe(true);
+
+ api.handleSocketClose();
+
+ expect(api.isConnected()).toBe(false);
+ expect(api.getPendingCount()).toBe(0);
+ });
+
+ it('should enforce max pending requests limit', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ // Mock the pending count to be at limit
+ for (let i = 0; i < 100; i++) {
+ (api as any).pending.set(`request-${i}`, { resolve: jest.fn(), reject: jest.fn() });
+ }
+
+ await expect(api.send('method', {})).rejects.toThrow('Too many concurrent API requests');
+ });
+
+ it('should reconnect after socket close', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ await api.send('method1', {});
+ api.handleSocketClose();
+
+ expect(api.isConnected()).toBe(false);
+
+ // Should reconnect on next request
+ await api.send('method2', {});
+ expect(api.isConnected()).toBe(true);
+ });
+
+ it('should reject with ESOCKETCLOSED code on socket close', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+
+ await api.send('method1', {});
+
+ // Add a pending request
+ const pendingPromise = new Promise((resolve, reject) => {
+ (api as any).pending.set('test-pending', { resolve, reject });
+ });
+
+ // Trigger socket close
+ api.handleSocketClose();
+
+ try {
+ await pendingPromise;
+ fail('Should have rejected');
+ } catch (error: any) {
+ expect(error.code).toBe('ESOCKETCLOSED');
+ expect(error.name).toBe('SocketClosedError');
+ expect(error.message).toContain('connection lifetime exceeded');
+ }
+ });
+
+ it('should track socket creation time', async () => {
+ const api = new MockPluginAPIImpl('/tmp/test.sock');
+ const beforeConnect = Date.now();
+
+ await api.send('method1', {});
+
+ const afterConnect = Date.now();
+ const socketCreatedAt = api.getSocketCreatedAt();
+
+ expect(socketCreatedAt).toBeGreaterThanOrEqual(beforeConnect);
+ expect(socketCreatedAt).toBeLessThanOrEqual(afterConnect);
+ });
+});
+
+describe('Plugin console implementation', () => {
+ interface LogEntry {
+ level: 'error' | 'warn' | 'info' | 'log' | 'debug' | 'result';
+ message: string;
+ }
+
+ function createMockPluginConsole(logs: LogEntry[]): Console {
+ const log = (level: LogEntry['level']) => (...args: any[]) => {
+ const message = args.map(arg =>
+ typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
+ ).join(' ');
+ logs.push({ level, message });
+ };
+
+ return {
+ log: log('log'),
+ info: log('info'),
+ warn: log('warn'),
+ error: log('error'),
+ debug: log('debug'),
+ } as Console;
+ }
+
+ it('should capture log messages', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.log('test message');
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0]).toEqual({ level: 'log', message: 'test message' });
+ });
+
+ it('should capture error messages', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.error('error occurred');
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0]).toEqual({ level: 'error', message: 'error occurred' });
+ });
+
+ it('should capture warn messages', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.warn('warning message');
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0]).toEqual({ level: 'warn', message: 'warning message' });
+ });
+
+ it('should stringify objects', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.log({ foo: 'bar', num: 42 });
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0].message).toBe('{"foo":"bar","num":42}');
+ });
+
+ it('should handle multiple arguments', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.log('Hello', 'world', 123);
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0].message).toBe('Hello world 123');
+ });
+
+ it('should handle mixed types', () => {
+ const logs: LogEntry[] = [];
+ const console = createMockPluginConsole(logs);
+
+ console.log('Count:', 42, { status: 'ok' });
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0].message).toBe('Count: 42 {"status":"ok"}');
+ });
+});
+
+describe('Plugin require blocking', () => {
+ const BLOCKED_MODULES = new Set([
+ 'fs', 'child_process', 'net', 'http', 'https',
+ 'cluster', 'process', 'vm', 'os', 'v8',
+ ]);
+
+ function createSandboxRequire(blockedModules: Set) {
+ return (id: string): any => {
+ if (blockedModules.has(id)) {
+ throw new Error(
+ `Module '${id}' is blocked for security. ` +
+ `Use the PluginAPI for network operations.`
+ );
+ }
+ // Allow other modules
+ return require(id);
+ };
+ }
+
+ it('should block dangerous built-in modules', () => {
+ const pluginRequire = createSandboxRequire(BLOCKED_MODULES);
+
+ expect(() => pluginRequire('fs')).toThrow('Module \'fs\' is blocked for security');
+ expect(() => pluginRequire('child_process')).toThrow('blocked for security');
+ expect(() => pluginRequire('net')).toThrow('blocked for security');
+ expect(() => pluginRequire('http')).toThrow('blocked for security');
+ });
+
+ it('should allow safe modules', () => {
+ const pluginRequire = createSandboxRequire(BLOCKED_MODULES);
+
+ // Should not throw
+ expect(() => pluginRequire('uuid')).not.toThrow();
+ });
+
+ it('should block with node: prefix', () => {
+ const extendedBlocked = new Set([
+ 'fs', 'node:fs',
+ 'net', 'node:net',
+ ]);
+ const pluginRequire = createSandboxRequire(extendedBlocked);
+
+ expect(() => pluginRequire('node:fs')).toThrow('blocked for security');
+ expect(() => pluginRequire('node:net')).toThrow('blocked for security');
+ });
+});
+
+describe('executePlugin function', () => {
+ interface ExecutorTask {
+ taskId: string;
+ pluginId: string;
+ compiledCode: string;
+ params: any;
+ headers?: Record;
+ socketPath: string;
+ httpRequestId?: string;
+ timeout: number;
+ }
+
+ interface ExecutorResult {
+ taskId: string;
+ success: boolean;
+ result?: any;
+ error?: {
+ message: string;
+ code?: string;
+ status?: number;
+ details?: any;
+ };
+ logs: any[];
+ }
+
+ async function mockExecuteInSandbox(task: ExecutorTask): Promise {
+ const logs: any[] = [];
+
+ try {
+ // Simulate plugin execution
+ if (task.compiledCode.includes('throw')) {
+ throw new Error('Plugin error');
+ }
+
+ if (task.compiledCode.includes('timeout')) {
+ await new Promise((resolve) => setTimeout(resolve, task.timeout + 100));
+ }
+
+ return {
+ taskId: task.taskId,
+ success: true,
+ result: { processed: task.params },
+ logs,
+ };
+ } catch (error: any) {
+ return {
+ taskId: task.taskId,
+ success: false,
+ error: {
+ message: error.message,
+ code: 'PLUGIN_ERROR',
+ status: 500,
+ },
+ logs,
+ };
+ }
+ }
+
+ it('should execute plugin successfully', async () => {
+ const task: ExecutorTask = {
+ taskId: 'task-1',
+ pluginId: 'plugin-1',
+ compiledCode: 'return params;',
+ params: { test: true },
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ };
+
+ const result = await mockExecuteInSandbox(task);
+
+ expect(result.success).toBe(true);
+ expect(result.taskId).toBe('task-1');
+ expect(result.result).toEqual({ processed: { test: true } });
+ });
+
+ it('should handle plugin errors', async () => {
+ const task: ExecutorTask = {
+ taskId: 'task-2',
+ pluginId: 'plugin-2',
+ compiledCode: 'throw new Error("test error");',
+ params: {},
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ };
+
+ const result = await mockExecuteInSandbox(task);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.message).toBe('Plugin error');
+ expect(result.error?.code).toBe('PLUGIN_ERROR');
+ });
+
+ it('should include headers in execution context', async () => {
+ const task: ExecutorTask = {
+ taskId: 'task-3',
+ pluginId: 'plugin-3',
+ compiledCode: 'return headers;',
+ params: {},
+ headers: { 'x-api-key': ['secret'] },
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ };
+
+ const result = await mockExecuteInSandbox(task);
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should respect execution timeout', async () => {
+ const task: ExecutorTask = {
+ taskId: 'task-4',
+ pluginId: 'plugin-4',
+ compiledCode: 'timeout',
+ params: {},
+ socketPath: '/tmp/test.sock',
+ timeout: 100,
+ };
+
+ // This would timeout in real execution
+ const startTime = Date.now();
+ await mockExecuteInSandbox(task);
+ const elapsed = Date.now() - startTime;
+
+ expect(elapsed).toBeGreaterThan(100);
+ });
+
+ it('should provide httpRequestId for tracing', async () => {
+ const task: ExecutorTask = {
+ taskId: 'task-5',
+ pluginId: 'plugin-5',
+ compiledCode: 'return httpRequestId;',
+ params: {},
+ socketPath: '/tmp/test.sock',
+ httpRequestId: 'http-123',
+ timeout: 5000,
+ };
+
+ const result = await mockExecuteInSandbox(task);
+ expect(result.success).toBe(true);
+ });
+});
+
+describe('Error handling in executor', () => {
+ it('should categorize SyntaxError', () => {
+ const error = new SyntaxError('Unexpected token');
+
+ const errorCode = error.name === 'SyntaxError' ? 'SYNTAX_ERROR' : 'PLUGIN_ERROR';
+
+ expect(errorCode).toBe('SYNTAX_ERROR');
+ });
+
+ it('should categorize TypeError', () => {
+ const error = new TypeError('Cannot read property');
+
+ const errorCode = error.name === 'TypeError' ? 'TYPE_ERROR' : 'PLUGIN_ERROR';
+
+ expect(errorCode).toBe('TYPE_ERROR');
+ });
+
+ it('should categorize ReferenceError', () => {
+ const error = new ReferenceError('x is not defined');
+
+ const errorCode = error.name === 'ReferenceError' ? 'REFERENCE_ERROR' : 'PLUGIN_ERROR';
+
+ expect(errorCode).toBe('REFERENCE_ERROR');
+ });
+
+ it('should handle timeout errors', () => {
+ const error: any = new Error('Timeout');
+ error.code = 'ERR_SCRIPT_EXECUTION_TIMEOUT';
+
+ const errorCode = error.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT' ? 'TIMEOUT' : 'PLUGIN_ERROR';
+ const errorStatus = errorCode === 'TIMEOUT' ? 504 : 500;
+
+ expect(errorCode).toBe('TIMEOUT');
+ expect(errorStatus).toBe(504);
+ });
+
+ it('should handle handler timeout errors', () => {
+ const error: any = new Error('Handler timeout');
+ error.code = 'ERR_HANDLER_TIMEOUT';
+
+ const errorCode = error.code === 'ERR_HANDLER_TIMEOUT' ? 'TIMEOUT' : 'PLUGIN_ERROR';
+
+ expect(errorCode).toBe('TIMEOUT');
+ });
+
+ it('should capture stack trace', () => {
+ const error = new Error('Test error');
+
+ const stack = error.stack?.split('\n').slice(0, 10).join('\n');
+
+ expect(stack).toBeDefined();
+ expect(stack).toContain('Test error');
+ });
+
+ it('should use custom status if provided', () => {
+ const error: any = new Error('Bad request');
+ error.status = 400;
+
+ const errorStatus = typeof error.status === 'number' ? error.status : 500;
+
+ expect(errorStatus).toBe(400);
+ });
+});
+
+describe('Safe stringify function', () => {
+ function safeStringify(value: unknown): string {
+ try {
+ return JSON.stringify(value, (_, v) => {
+ if (typeof v === 'bigint') {
+ return v.toString() + 'n';
+ }
+ return v;
+ });
+ } catch {
+ try {
+ return String(value);
+ } catch {
+ return '[Unstringifiable value]';
+ }
+ }
+ }
+
+ it('should stringify simple values', () => {
+ expect(safeStringify('hello')).toBe('"hello"');
+ expect(safeStringify(42)).toBe('42');
+ expect(safeStringify(true)).toBe('true');
+ expect(safeStringify(null)).toBe('null');
+ });
+
+ it('should stringify objects', () => {
+ const obj = { foo: 'bar', num: 42 };
+ expect(safeStringify(obj)).toBe('{"foo":"bar","num":42}');
+ });
+
+ it('should handle BigInt', () => {
+ const bigInt = BigInt(123456789);
+ const result = safeStringify(bigInt);
+ expect(result).toContain('123456789');
+ });
+
+ it('should handle circular references', () => {
+ const obj: any = { name: 'test' };
+ obj.self = obj; // Circular reference
+
+ const result = safeStringify(obj);
+ // Will use String() fallback
+ expect(result).toBeTruthy();
+ });
+
+ it('should handle undefined', () => {
+ const result = safeStringify(undefined);
+ // JSON.stringify returns undefined for undefined
+ expect(result === undefined || result === 'undefined').toBe(true);
+ });
+});
+
+describe('Memory-aware script caching', () => {
+ it('should check memory periodically', () => {
+ const lastCheck = Date.now();
+ const checkInterval = 5000;
+
+ const shouldCheck = Date.now() - lastCheck >= checkInterval;
+
+ // Initially should check (time has passed in test)
+ expect(typeof shouldCheck).toBe('boolean');
+ });
+
+ it('should calculate heap usage ratio correctly', () => {
+ const heapLimit = 1000 * 1024 * 1024; // 1000MB
+ const heapUsed = 700 * 1024 * 1024; // 700MB
+
+ const ratio = heapUsed / heapLimit;
+
+ expect(ratio).toBe(0.7);
+ });
+
+ it('should evict 25% at 70% heap usage', () => {
+ const cacheSize = 100;
+ const heapRatio = 0.70;
+
+ const evictCount = Math.max(1, Math.ceil(cacheSize * 0.25));
+
+ expect(evictCount).toBe(25);
+ });
+
+ it('should evict 50% at 85% heap usage', () => {
+ const cacheSize = 100;
+ const heapRatio = 0.85;
+
+ const evictCount = Math.max(1, Math.ceil(cacheSize * 0.5));
+
+ expect(evictCount).toBe(50);
+ });
+});
+
+describe('Context and cache lifecycle', () => {
+ it('should release context to pool after execution', () => {
+ const pool = { released: false };
+ const context = { id: 'test-context' };
+
+ // Simulate execution complete
+ pool.released = true;
+
+ expect(pool.released).toBe(true);
+ });
+
+ it('should close API socket after execution', () => {
+ const api = {
+ closed: false,
+ close() {
+ this.closed = true;
+ },
+ };
+
+ api.close();
+
+ expect(api.closed).toBe(true);
+ });
+
+ it('should disconnect KV store after execution', async () => {
+ const kv = {
+ disconnected: false,
+ async disconnect() {
+ this.disconnected = true;
+ },
+ };
+
+ await kv.disconnect();
+
+ expect(kv.disconnected).toBe(true);
+ });
+
+ it('should handle cleanup errors gracefully', () => {
+ const api = {
+ close() {
+ throw new Error('Close failed');
+ },
+ };
+
+ // Should not throw
+ expect(() => {
+ try {
+ api.close();
+ } catch {
+ // Ignore cleanup errors
+ }
+ }).not.toThrow();
+ });
+});
diff --git a/plugins/tests/lib/plugin.test.ts b/plugins/tests/lib/plugin.test.ts
index a67f9be36..b64901541 100644
--- a/plugins/tests/lib/plugin.test.ts
+++ b/plugins/tests/lib/plugin.test.ts
@@ -213,8 +213,14 @@ describe('PluginAPI', () => {
expect(mockWrite).toHaveBeenCalled();
});
- it('should throw error if write fails', async () => {
- mockWrite.mockReturnValue(false);
+ it('should throw error if write callback returns error', async () => {
+ // Simulate write error via callback (this is how Node.js socket.write reports errors)
+ const writeError = new Error('EPIPE: broken pipe');
+ mockWrite.mockImplementation((data: string, callback: (error?: Error) => void) => {
+ // Call the callback with an error to simulate write failure
+ callback(writeError);
+ return true;
+ });
const payload: NetworkTransactionRequest = {
to: '0x1234567890123456789012345678901234567890',
@@ -225,7 +231,7 @@ describe('PluginAPI', () => {
};
await expect(pluginAPI._send('test-relayer', 'sendTransaction', payload))
- .rejects.toThrow('Failed to send message to relayer');
+ .rejects.toThrow('EPIPE: broken pipe');
});
});
@@ -278,6 +284,160 @@ describe('PluginAPI', () => {
});
});
+describe('Socket error handling', () => {
+ let pluginAPI: DefaultPluginAPI;
+ let mockSocket: jest.Mocked;
+ let mockWrite: jest.Mock;
+ let mockEnd: jest.Mock;
+ let mockDestroy: jest.Mock;
+ let eventHandlers: Map;
+
+ beforeEach(() => {
+ eventHandlers = new Map();
+ mockWrite = jest.fn().mockReturnValue(true);
+ mockEnd = jest.fn();
+ mockDestroy = jest.fn();
+
+ mockSocket = {
+ write: mockWrite,
+ end: mockEnd,
+ destroy: mockDestroy,
+ on: jest.fn((event: string, handler: Function) => {
+ eventHandlers.set(event, handler);
+ return mockSocket;
+ }),
+ } as any;
+
+ jest.spyOn(net, 'createConnection').mockReturnValue(mockSocket);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should reject pending requests when socket closes', async () => {
+ pluginAPI = new DefaultPluginAPI('/tmp/test.sock');
+
+ // Simulate established connection by triggering connect event
+ const connectHandler = eventHandlers.get('connect');
+ if (connectHandler) {
+ connectHandler();
+ }
+
+ const payload: NetworkTransactionRequest = {
+ to: '0x1234567890123456789012345678901234567890',
+ value: 1000000,
+ data: '0x',
+ gas_limit: 21000,
+ speed: Speed.FAST,
+ };
+
+ // Start the send (won't complete)
+ const sendPromise = pluginAPI._send('test-relayer', 'sendTransaction', payload);
+
+ // Trigger socket close
+ const closeHandler = eventHandlers.get('close');
+ if (closeHandler) {
+ closeHandler();
+ }
+
+ // Should reject with SocketClosedError
+ await expect(sendPromise).rejects.toThrow('Socket closed by server');
+ });
+
+ it('should have ESOCKETCLOSED code on socket close error', async () => {
+ pluginAPI = new DefaultPluginAPI('/tmp/test.sock');
+
+ // Simulate established connection
+ const connectHandler = eventHandlers.get('connect');
+ if (connectHandler) {
+ connectHandler();
+ }
+
+ const payload: NetworkTransactionRequest = {
+ to: '0x1234567890123456789012345678901234567890',
+ value: 1000000,
+ data: '0x',
+ gas_limit: 21000,
+ speed: Speed.FAST,
+ };
+
+ const sendPromise = pluginAPI._send('test-relayer', 'sendTransaction', payload);
+
+ // Trigger socket close
+ const closeHandler = eventHandlers.get('close');
+ if (closeHandler) {
+ closeHandler();
+ }
+
+ try {
+ await sendPromise;
+ fail('Should have thrown');
+ } catch (error: any) {
+ expect(error.code).toBe('ESOCKETCLOSED');
+ expect(error.name).toBe('SocketClosedError');
+ }
+ });
+
+ it('should handle multiple pending requests on socket close', async () => {
+ pluginAPI = new DefaultPluginAPI('/tmp/test.sock');
+
+ // Simulate established connection
+ const connectHandler = eventHandlers.get('connect');
+ if (connectHandler) {
+ connectHandler();
+ }
+
+ const payload: NetworkTransactionRequest = {
+ to: '0x1234567890123456789012345678901234567890',
+ value: 1000000,
+ data: '0x',
+ gas_limit: 21000,
+ speed: Speed.FAST,
+ };
+
+ // Start multiple sends
+ const promises = [
+ pluginAPI._send('test-relayer', 'sendTransaction', payload),
+ pluginAPI._send('test-relayer', 'getRelayer', {}),
+ pluginAPI._send('test-relayer', 'getRelayerStatus', {}),
+ ];
+
+ expect(pluginAPI.pending.size).toBe(3);
+
+ // Trigger socket close
+ const closeHandler = eventHandlers.get('close');
+ if (closeHandler) {
+ closeHandler();
+ }
+
+ // All should reject
+ for (const promise of promises) {
+ await expect(promise).rejects.toThrow();
+ }
+
+ // Pending should be cleared
+ expect(pluginAPI.pending.size).toBe(0);
+ });
+
+ it('should reject connection promise on socket error during connect', async () => {
+ pluginAPI = new DefaultPluginAPI('/tmp/test.sock');
+
+ // Trigger error before connection is established
+ const errorHandler = eventHandlers.get('error');
+ const socketError = new Error('ECONNREFUSED');
+
+ // The connection promise should reject
+ const connectionPromise = (pluginAPI as any)._connectionPromise;
+
+ if (errorHandler) {
+ errorHandler(socketError);
+ }
+
+ await expect(connectionPromise).rejects.toThrow('ECONNREFUSED');
+ });
+});
+
describe('PluginContext Headers', () => {
it('should have correct PluginHeaders type structure', () => {
// Test type structure: Record
diff --git a/plugins/tests/lib/pool-server.test.ts b/plugins/tests/lib/pool-server.test.ts
new file mode 100644
index 000000000..3bb6d2600
--- /dev/null
+++ b/plugins/tests/lib/pool-server.test.ts
@@ -0,0 +1,567 @@
+import * as net from 'node:net';
+import * as fs from 'node:fs';
+import * as v8 from 'node:v8';
+
+// Mock dependencies before importing the module
+jest.mock('../../lib/worker-pool', () => ({
+ WorkerPoolManager: jest.fn().mockImplementation(() => ({
+ initialize: jest.fn().mockResolvedValue(undefined),
+ runPlugin: jest.fn().mockResolvedValue({
+ success: true,
+ result: { data: 'test' },
+ logs: [],
+ }),
+ precompilePlugin: jest.fn().mockResolvedValue({
+ code: 'compiled-code',
+ warnings: [],
+ }),
+ precompilePluginSource: jest.fn().mockResolvedValue({
+ code: 'compiled-code',
+ warnings: [],
+ }),
+ cacheCompiledCode: jest.fn(),
+ invalidatePlugin: jest.fn(),
+ getStats: jest.fn().mockReturnValue({
+ uptime: 1000,
+ pool: { completed: 10, queued: 0 },
+ execution: { total: 10, successRate: 1.0 },
+ }),
+ shutdown: jest.fn().mockResolvedValue(undefined),
+ clearCache: jest.fn(),
+ })),
+}));
+
+jest.mock('node:fs', () => ({
+ unlinkSync: jest.fn(),
+ statSync: jest.fn().mockReturnValue({
+ mode: 0o755,
+ isSocket: () => true,
+ }),
+}));
+
+// Import after mocks are set up
+const { WorkerPoolManager } = jest.requireMock('../../lib/worker-pool') as any;
+
+describe('PoolServerMemoryMonitor logic', () => {
+ describe('Memory pressure detection', () => {
+ let originalMemoryUsage: typeof process.memoryUsage;
+
+ beforeEach(() => {
+ originalMemoryUsage = process.memoryUsage;
+ });
+
+ afterEach(() => {
+ process.memoryUsage = originalMemoryUsage;
+ });
+
+ it('should detect warning threshold at 75% heap usage', () => {
+ const heapLimit = 1000 * 1024 * 1024; // 1000MB
+ const heapUsed = 750 * 1024 * 1024; // 750MB (75%)
+
+ const ratio = heapUsed / heapLimit;
+ expect(ratio).toBeGreaterThanOrEqual(0.75);
+ expect(ratio).toBeLessThan(0.80);
+ });
+
+ it('should detect GC threshold at 80% heap usage', () => {
+ const heapLimit = 1000 * 1024 * 1024;
+ const heapUsed = 800 * 1024 * 1024; // 80%
+
+ const ratio = heapUsed / heapLimit;
+ expect(ratio).toBeGreaterThanOrEqual(0.80);
+ expect(ratio).toBeLessThan(0.85);
+ });
+
+ it('should detect critical threshold at 85% heap usage', () => {
+ const heapLimit = 1000 * 1024 * 1024;
+ const heapUsed = 850 * 1024 * 1024; // 85%
+
+ const ratio = heapUsed / heapLimit;
+ expect(ratio).toBeGreaterThanOrEqual(0.85);
+ expect(ratio).toBeLessThan(0.92);
+ });
+
+ it('should detect emergency threshold at 92% heap usage', () => {
+ const heapLimit = 1000 * 1024 * 1024;
+ const heapUsed = 920 * 1024 * 1024; // 92%
+
+ const ratio = heapUsed / heapLimit;
+ expect(ratio).toBeGreaterThanOrEqual(0.92);
+ });
+ });
+});
+
+describe('PoolServer message handling', () => {
+ let mockSocket: Partial;
+ let dataCallback: ((data: Buffer) => void) | undefined;
+ let errorCallback: ((err: Error) => void) | undefined;
+ let closeCallback: (() => void) | undefined;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Reset WorkerPoolManager mock
+ (WorkerPoolManager as jest.Mock).mockImplementation(() => ({
+ initialize: jest.fn().mockResolvedValue(undefined),
+ runPlugin: jest.fn().mockResolvedValue({
+ success: true,
+ result: { data: 'test' },
+ logs: [],
+ }),
+ precompilePlugin: jest.fn().mockResolvedValue({
+ code: 'compiled-code',
+ warnings: [],
+ }),
+ precompilePluginSource: jest.fn().mockResolvedValue({
+ code: 'compiled-code',
+ warnings: [],
+ }),
+ cacheCompiledCode: jest.fn(),
+ invalidatePlugin: jest.fn(),
+ getStats: jest.fn().mockReturnValue({
+ uptime: 1000,
+ pool: { completed: 10, queued: 0 },
+ execution: { total: 10, successRate: 1.0 },
+ }),
+ shutdown: jest.fn().mockResolvedValue(undefined),
+ clearCache: jest.fn(),
+ }));
+
+ mockSocket = {
+ setKeepAlive: jest.fn(),
+ setNoDelay: jest.fn(),
+ write: jest.fn(),
+ writable: true,
+ on: jest.fn().mockReturnThis(),
+ } as any;
+ });
+
+ describe('execute message', () => {
+ it('should handle valid execute message', async () => {
+ const message = {
+ type: 'execute',
+ taskId: 'task-1',
+ pluginId: 'plugin-1',
+ compiledCode: 'code',
+ params: { test: true },
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ };
+
+ // Simulate the message flow
+ const pool = new WorkerPoolManager({} as any);
+ const result = await pool.runPlugin({
+ pluginId: message.pluginId,
+ compiledCode: message.compiledCode,
+ params: message.params,
+ socketPath: message.socketPath,
+ timeout: message.timeout,
+ });
+
+ expect(result).toEqual({
+ success: true,
+ result: { data: 'test' },
+ logs: [],
+ });
+ expect(pool.runPlugin).toHaveBeenCalledWith({
+ pluginId: 'plugin-1',
+ compiledCode: 'code',
+ params: { test: true },
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ });
+ });
+
+ it('should handle execute message with error', async () => {
+ const pool = new WorkerPoolManager({} as any);
+ (pool.runPlugin as jest.Mock).mockResolvedValue({
+ success: false,
+ error: {
+ message: 'Plugin error',
+ code: 'PLUGIN_ERROR',
+ status: 500,
+ },
+ logs: [],
+ });
+
+ const result = await pool.runPlugin({
+ pluginId: 'plugin-1',
+ compiledCode: 'code',
+ params: {},
+ socketPath: '/tmp/test.sock',
+ timeout: 5000,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toEqual({
+ message: 'Plugin error',
+ code: 'PLUGIN_ERROR',
+ status: 500,
+ });
+ });
+ });
+
+ describe('precompile message', () => {
+ it('should handle precompile with source code', async () => {
+ const pool = new WorkerPoolManager({} as any);
+ const result = await pool.precompilePluginSource('plugin-1', 'source code');
+
+ expect(result).toEqual({
+ code: 'compiled-code',
+ warnings: [],
+ });
+ expect(pool.precompilePluginSource).toHaveBeenCalledWith('plugin-1', 'source code');
+ });
+
+ it('should handle precompile with plugin path', async () => {
+ const pool = new WorkerPoolManager({} as any);
+ const result = await pool.precompilePlugin('/path/to/plugin.js');
+
+ expect(result).toEqual({
+ code: 'compiled-code',
+ warnings: [],
+ });
+ expect(pool.precompilePlugin).toHaveBeenCalledWith('/path/to/plugin.js');
+ });
+ });
+
+ describe('cache message', () => {
+ it('should cache compiled code', () => {
+ const pool = new WorkerPoolManager({} as any);
+ pool.cacheCompiledCode('plugin-1', 'compiled-code');
+
+ expect(pool.cacheCompiledCode).toHaveBeenCalledWith('plugin-1', 'compiled-code');
+ });
+ });
+
+ describe('invalidate message', () => {
+ it('should invalidate plugin cache', () => {
+ const pool = new WorkerPoolManager({} as any);
+ pool.invalidatePlugin('plugin-1');
+
+ expect(pool.invalidatePlugin).toHaveBeenCalledWith('plugin-1');
+ });
+ });
+
+ describe('stats message', () => {
+ it('should return pool statistics', () => {
+ const pool = new WorkerPoolManager({} as any);
+ const stats = pool.getStats();
+
+ expect(stats).toEqual({
+ uptime: 1000,
+ pool: { completed: 10, queued: 0 },
+ execution: { total: 10, successRate: 1.0 },
+ });
+ });
+ });
+
+ describe('health message', () => {
+ it('should return health status with memory info', () => {
+ const pool = new WorkerPoolManager({} as any);
+ const stats = pool.getStats();
+
+ const health = {
+ status: 'healthy',
+ uptime: stats.uptime,
+ memory: {
+ heapUsed: process.memoryUsage().heapUsed,
+ heapTotal: process.memoryUsage().heapTotal,
+ rss: process.memoryUsage().rss,
+ },
+ pool: {
+ completed: stats.pool.completed,
+ queued: stats.pool.queued,
+ },
+ execution: stats.execution,
+ };
+
+ expect(health.status).toBe('healthy');
+ expect(health.uptime).toBe(1000);
+ expect(health.memory).toBeDefined();
+ expect(health.pool).toBeDefined();
+ expect(health.execution).toBeDefined();
+ });
+ });
+
+ describe('shutdown message', () => {
+ it('should initiate graceful shutdown', () => {
+ const pool = new WorkerPoolManager({} as any);
+
+ let shuttingDown = false;
+ const activeRequests = 0;
+
+ const response = {
+ status: 'draining',
+ activeRequests,
+ timeoutMs: 30000,
+ };
+
+ expect(response.status).toBe('draining');
+ expect(response.activeRequests).toBe(0);
+ expect(response.timeoutMs).toBe(30000);
+ });
+
+ it('should indicate already shutting down', () => {
+ let shuttingDown = true;
+ const activeRequests = 2;
+
+ const response = {
+ status: 'already_shutting_down',
+ activeRequests,
+ };
+
+ expect(response.status).toBe('already_shutting_down');
+ expect(response.activeRequests).toBe(2);
+ });
+ });
+
+ describe('unknown message type', () => {
+ it('should handle unknown message type', () => {
+ const unknownType = 'invalid_type';
+ const error = {
+ message: `Unknown message type: ${unknownType}`,
+ code: 'UNKNOWN_MESSAGE_TYPE',
+ };
+
+ expect(error.message).toContain('Unknown message type');
+ expect(error.code).toBe('UNKNOWN_MESSAGE_TYPE');
+ });
+ });
+});
+
+describe('PoolServer connection handling', () => {
+ it('should enable keep-alive and no-delay on socket', () => {
+ const mockSocket: any = {
+ setKeepAlive: jest.fn(),
+ setNoDelay: jest.fn(),
+ on: jest.fn(),
+ };
+
+ mockSocket.setKeepAlive(true, 30000);
+ mockSocket.setNoDelay(true);
+
+ expect(mockSocket.setKeepAlive).toHaveBeenCalledWith(true, 30000);
+ expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true);
+ });
+
+ it('should parse newline-delimited JSON messages', () => {
+ const buffer = '{"type":"execute","taskId":"1"}\n{"type":"stats","taskId":"2"}\n';
+ const messages: any[] = [];
+
+ let remaining = buffer;
+ let newlineIndex;
+ while ((newlineIndex = remaining.indexOf('\n')) !== -1) {
+ const line = remaining.slice(0, newlineIndex);
+ remaining = remaining.slice(newlineIndex + 1);
+ if (line.trim()) {
+ messages.push(JSON.parse(line));
+ }
+ }
+
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toEqual({ type: 'execute', taskId: '1' });
+ expect(messages[1]).toEqual({ type: 'stats', taskId: '2' });
+ });
+
+ it('should handle partial messages in buffer', () => {
+ let buffer = '{"type":"exe';
+ const messages: any[] = [];
+
+ // First chunk
+ let newlineIndex = buffer.indexOf('\n');
+ expect(newlineIndex).toBe(-1);
+
+ // Second chunk completes the message
+ buffer += 'cute","taskId":"1"}\n';
+ newlineIndex = buffer.indexOf('\n');
+ expect(newlineIndex).not.toBe(-1);
+
+ const line = buffer.slice(0, newlineIndex);
+ messages.push(JSON.parse(line));
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toEqual({ type: 'execute', taskId: '1' });
+ });
+
+ it('should handle socket errors gracefully', () => {
+ const mockSocket: any = {
+ on: jest.fn((event: string, callback: any) => {
+ if (event === 'error') {
+ // Simulate ECONNRESET error
+ callback({ code: 'ECONNRESET', message: 'Connection reset' });
+ }
+ }),
+ };
+
+ let errorReceived = false;
+ mockSocket.on('error', (err: any) => {
+ errorReceived = true;
+ expect(err.code).toBe('ECONNRESET');
+ });
+
+ expect(errorReceived).toBe(true);
+ });
+});
+
+describe('PoolServer graceful shutdown', () => {
+ it('should wait for active requests to complete', async () => {
+ let activeRequests = 3;
+ const checkInterval = 10;
+ const timeoutMs = 1000;
+
+ // Simulate draining
+ const drainPromise = new Promise((resolve) => {
+ const interval = setInterval(() => {
+ activeRequests--;
+ if (activeRequests === 0) {
+ clearInterval(interval);
+ resolve();
+ }
+ }, checkInterval);
+ });
+
+ await drainPromise;
+ expect(activeRequests).toBe(0);
+ });
+
+ it('should force shutdown after timeout', async () => {
+ let activeRequests = 5;
+ const checkInterval = 10;
+ const timeoutMs = 50;
+
+ const startTime = Date.now();
+ let timedOut = false;
+
+ const drainPromise = new Promise((resolve) => {
+ const interval = setInterval(() => {
+ const elapsed = Date.now() - startTime;
+ if (elapsed >= timeoutMs) {
+ timedOut = true;
+ clearInterval(interval);
+ resolve();
+ }
+ }, checkInterval);
+ });
+
+ await drainPromise;
+ expect(timedOut).toBe(true);
+ expect(activeRequests).toBeGreaterThan(0); // Some requests still active
+ });
+
+ it('should track active requests properly', () => {
+ let activeRequests = 0;
+
+ // Execute request
+ activeRequests++;
+ expect(activeRequests).toBe(1);
+
+ // Complete request
+ activeRequests--;
+ expect(activeRequests).toBe(0);
+
+ // Multiple requests
+ activeRequests += 3;
+ expect(activeRequests).toBe(3);
+
+ activeRequests -= 2;
+ expect(activeRequests).toBe(1);
+ });
+
+ it('should reject new requests during shutdown', () => {
+ let shuttingDown = false;
+ let messageType: string = 'execute';
+
+ // Allow before shutdown
+ let shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health';
+ expect(shouldReject).toBe(false);
+
+ // Reject during shutdown
+ shuttingDown = true;
+ shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health';
+ expect(shouldReject).toBe(true);
+
+ // But still allow shutdown and health
+ messageType = 'shutdown';
+ shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health';
+ expect(shouldReject).toBe(false);
+
+ messageType = 'health';
+ shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health';
+ expect(shouldReject).toBe(false);
+ });
+});
+
+describe('PoolServer cache eviction', () => {
+ it('should evict cache on memory pressure', () => {
+ const pool = new WorkerPoolManager({} as any);
+ pool.clearCache();
+
+ expect(pool.clearCache).toHaveBeenCalled();
+ });
+
+ it('should handle cache eviction errors gracefully', () => {
+ const pool = new WorkerPoolManager({} as any);
+ (pool.clearCache as jest.Mock).mockImplementation(() => {
+ throw new Error('Cache eviction failed');
+ });
+
+ try {
+ pool.clearCache();
+ } catch (err) {
+ expect((err as Error).message).toBe('Cache eviction failed');
+ }
+ });
+});
+
+describe('PoolServer configuration', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should use environment variables for configuration', () => {
+ process.env.PLUGIN_MAX_CONCURRENCY = '4096';
+ process.env.PLUGIN_POOL_MIN_THREADS = '4';
+ process.env.PLUGIN_POOL_MAX_THREADS = '16';
+ process.env.PLUGIN_POOL_CONCURRENT_TASKS = '8';
+ process.env.PLUGIN_POOL_IDLE_TIMEOUT = '30000';
+
+ const config = {
+ maxConcurrency: parseInt(process.env.PLUGIN_MAX_CONCURRENCY, 10),
+ minThreads: parseInt(process.env.PLUGIN_POOL_MIN_THREADS, 10),
+ maxThreads: parseInt(process.env.PLUGIN_POOL_MAX_THREADS, 10),
+ concurrentTasks: parseInt(process.env.PLUGIN_POOL_CONCURRENT_TASKS, 10),
+ idleTimeout: parseInt(process.env.PLUGIN_POOL_IDLE_TIMEOUT, 10),
+ };
+
+ expect(config.maxConcurrency).toBe(4096);
+ expect(config.minThreads).toBe(4);
+ expect(config.maxThreads).toBe(16);
+ expect(config.concurrentTasks).toBe(8);
+ expect(config.idleTimeout).toBe(30000);
+ });
+
+ it('should fall back to defaults when env vars not set', () => {
+ delete process.env.PLUGIN_MAX_CONCURRENCY;
+ delete process.env.PLUGIN_POOL_MIN_THREADS;
+
+ const maxConcurrency = parseInt(process.env.PLUGIN_MAX_CONCURRENCY || '2048', 10);
+ const minThreads = parseInt(process.env.PLUGIN_POOL_MIN_THREADS || '2', 10);
+
+ expect(maxConcurrency).toBe(2048);
+ expect(minThreads).toBe(2);
+ });
+
+ it('should use socket backlog from environment', () => {
+ process.env.PLUGIN_POOL_SOCKET_BACKLOG = '2048';
+
+ const backlog = parseInt(process.env.PLUGIN_POOL_SOCKET_BACKLOG, 10);
+ expect(backlog).toBe(2048);
+ });
+});
diff --git a/plugins/tests/lib/worker-pool.test.ts b/plugins/tests/lib/worker-pool.test.ts
new file mode 100644
index 000000000..724f85b2a
--- /dev/null
+++ b/plugins/tests/lib/worker-pool.test.ts
@@ -0,0 +1,454 @@
+import '@jest/globals';
+
+// Since the classes are not exported, we'll test them via their behavior
+// These tests validate the logic patterns used in worker-pool.ts
+
+describe('CompiledCodeCache logic', () => {
+ // Simulating the cache logic for testing
+
+ interface CacheEntry {
+ code: string;
+ timestamp: number;
+ size: number;
+ }
+
+ class MockCache {
+ private cache = new Map();
+ private totalSize = 0;
+ private readonly maxSize: number;
+ private readonly ttlMs: number;
+
+ constructor(maxSizeMB: number = 100, ttlMinutes: number = 60) {
+ this.maxSize = maxSizeMB * 1024 * 1024;
+ this.ttlMs = ttlMinutes * 60 * 1000;
+ }
+
+ get(key: string): string | null {
+ const entry = this.cache.get(key);
+ if (!entry) return null;
+
+ // Check expiration
+ if (Date.now() - entry.timestamp > this.ttlMs) {
+ this.delete(key);
+ return null;
+ }
+
+ // Update timestamp for LRU
+ entry.timestamp = Date.now();
+ return entry.code;
+ }
+
+ set(key: string, code: string): void {
+ const size = Buffer.byteLength(code, 'utf8');
+
+ // Delete existing entry if present
+ if (this.cache.has(key)) {
+ this.delete(key);
+ }
+
+ // Evict if over size limit
+ while (this.totalSize + size > this.maxSize && this.cache.size > 0) {
+ this.evictOldest();
+ }
+
+ this.cache.set(key, { code, timestamp: Date.now(), size });
+ this.totalSize += size;
+ }
+
+ delete(key: string): boolean {
+ const entry = this.cache.get(key);
+ if (entry) {
+ this.totalSize -= entry.size;
+ return this.cache.delete(key);
+ }
+ return false;
+ }
+
+ clear(): void {
+ this.cache.clear();
+ this.totalSize = 0;
+ }
+
+ has(key: string): boolean {
+ return this.cache.has(key);
+ }
+
+ get size(): number {
+ return this.cache.size;
+ }
+
+ get currentSize(): number {
+ return this.totalSize;
+ }
+
+ private evictOldest(): void {
+ let oldest: string | null = null;
+ let oldestTime = Infinity;
+
+ for (const [key, entry] of this.cache) {
+ if (entry.timestamp < oldestTime) {
+ oldestTime = entry.timestamp;
+ oldest = key;
+ }
+ }
+
+ if (oldest) {
+ this.delete(oldest);
+ }
+ }
+
+ evictExpired(): number {
+ const now = Date.now();
+ let evicted = 0;
+
+ for (const [key, entry] of this.cache) {
+ if (now - entry.timestamp > this.ttlMs) {
+ this.delete(key);
+ evicted++;
+ }
+ }
+
+ return evicted;
+ }
+ }
+
+ let cache: MockCache;
+
+ beforeEach(() => {
+ cache = new MockCache(1, 60); // 1MB max, 60 min TTL
+ });
+
+ describe('basic operations', () => {
+ it('should store and retrieve values', () => {
+ cache.set('plugin1', 'code1');
+ expect(cache.get('plugin1')).toBe('code1');
+ });
+
+ it('should return null for missing keys', () => {
+ expect(cache.get('nonexistent')).toBeNull();
+ });
+
+ it('should delete entries', () => {
+ cache.set('plugin1', 'code1');
+ expect(cache.delete('plugin1')).toBe(true);
+ expect(cache.get('plugin1')).toBeNull();
+ });
+
+ it('should check if key exists', () => {
+ cache.set('plugin1', 'code1');
+ expect(cache.has('plugin1')).toBe(true);
+ expect(cache.has('nonexistent')).toBe(false);
+ });
+
+ it('should clear all entries', () => {
+ cache.set('plugin1', 'code1');
+ cache.set('plugin2', 'code2');
+ cache.clear();
+ expect(cache.size).toBe(0);
+ expect(cache.currentSize).toBe(0);
+ });
+ });
+
+ describe('size tracking', () => {
+ it('should track total size', () => {
+ const code = 'a'.repeat(1000); // ~1KB
+ cache.set('plugin1', code);
+ expect(cache.currentSize).toBeGreaterThan(0);
+ });
+
+ it('should update size on delete', () => {
+ const code = 'a'.repeat(1000);
+ cache.set('plugin1', code);
+ const sizeAfterSet = cache.currentSize;
+
+ cache.delete('plugin1');
+ expect(cache.currentSize).toBe(sizeAfterSet - Buffer.byteLength(code, 'utf8'));
+ });
+ });
+
+ describe('LRU eviction', () => {
+ it('should evict oldest entry when full', () => {
+ // Create a smaller cache for testing
+ const smallCache = new MockCache(0.001, 60); // ~1KB max
+
+ smallCache.set('old', 'a'.repeat(400));
+ // Wait a bit to ensure different timestamp
+ smallCache.set('new', 'b'.repeat(400));
+
+ // This should trigger eviction of 'old'
+ smallCache.set('newest', 'c'.repeat(400));
+
+ expect(smallCache.has('old')).toBe(false);
+ expect(smallCache.has('newest')).toBe(true);
+ });
+
+ it('should update timestamp on get for LRU', async () => {
+ // Use even smaller cache - 500 bytes max
+ const smallCache = new MockCache(0.0005, 60);
+
+ smallCache.set('first', 'a'.repeat(200));
+
+ // Wait to ensure different timestamps
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ smallCache.set('second', 'b'.repeat(200));
+
+ // Wait again
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // Access 'first' to update its timestamp (makes it newer than 'second')
+ smallCache.get('first');
+
+ // Wait again
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // Add new entry - should evict 'second' (oldest access time)
+ smallCache.set('third', 'c'.repeat(200));
+
+ // Verify 'first' was accessed more recently and survived
+ expect(smallCache.has('first')).toBe(true);
+ // 'second' was not accessed and should be evicted (oldest)
+ expect(smallCache.has('second')).toBe(false);
+ });
+ });
+
+ describe('TTL expiration', () => {
+ it('should return null for expired entries', () => {
+ // Create cache with 1ms TTL for testing
+ const shortTTLCache = new MockCache(1, 0.00001); // ~0.6ms TTL
+
+ shortTTLCache.set('plugin1', 'code1');
+
+ // Wait for expiration
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ expect(shortTTLCache.get('plugin1')).toBeNull();
+ resolve();
+ }, 10);
+ });
+ });
+ });
+});
+
+describe('MemoryMonitor logic', () => {
+ // Simulating memory monitor logic for testing
+
+ interface HeapStats {
+ heapUsed: number;
+ heapLimit: number;
+ }
+
+ class MockMemoryMonitor {
+ private readonly warningThreshold = 0.75;
+ private readonly gcThreshold = 0.80;
+ private readonly criticalThreshold = 0.85;
+ private readonly emergencyThreshold = 0.92;
+ private consecutiveHighPressure = 0;
+ private emergencyTriggered = false;
+
+ private onWarning: (() => void) | null = null;
+ private onCritical: (() => void) | null = null;
+ private onEmergency: (() => void) | null = null;
+
+ setCallbacks(
+ onWarning: () => void,
+ onCritical: () => void,
+ onEmergency: () => void
+ ): void {
+ this.onWarning = onWarning;
+ this.onCritical = onCritical;
+ this.onEmergency = onEmergency;
+ }
+
+ check(stats: HeapStats): string | null {
+ const ratio = stats.heapUsed / stats.heapLimit;
+
+ if (ratio >= this.emergencyThreshold) {
+ if (!this.emergencyTriggered) {
+ this.emergencyTriggered = true;
+ this.onEmergency?.();
+ return 'emergency';
+ }
+ return 'emergency-blocked';
+ }
+
+ if (ratio >= this.criticalThreshold) {
+ this.consecutiveHighPressure++;
+ this.onCritical?.();
+ return 'critical';
+ }
+
+ if (ratio >= this.gcThreshold) {
+ return 'gc';
+ }
+
+ if (ratio >= this.warningThreshold) {
+ this.onWarning?.();
+ return 'warning';
+ }
+
+ this.consecutiveHighPressure = 0;
+ return null;
+ }
+
+ reset(): void {
+ this.emergencyTriggered = false;
+ this.consecutiveHighPressure = 0;
+ }
+
+ get isEmergencyTriggered(): boolean {
+ return this.emergencyTriggered;
+ }
+ }
+
+ let monitor: MockMemoryMonitor;
+
+ beforeEach(() => {
+ monitor = new MockMemoryMonitor();
+ });
+
+ describe('threshold detection', () => {
+ it('should return null for normal memory usage', () => {
+ const result = monitor.check({ heapUsed: 500, heapLimit: 1000 }); // 50%
+ expect(result).toBeNull();
+ });
+
+ it('should detect warning threshold (75%)', () => {
+ const result = monitor.check({ heapUsed: 760, heapLimit: 1000 });
+ expect(result).toBe('warning');
+ });
+
+ it('should detect GC threshold (80%)', () => {
+ const result = monitor.check({ heapUsed: 810, heapLimit: 1000 });
+ expect(result).toBe('gc');
+ });
+
+ it('should detect critical threshold (85%)', () => {
+ const result = monitor.check({ heapUsed: 860, heapLimit: 1000 });
+ expect(result).toBe('critical');
+ });
+
+ it('should detect emergency threshold (92%)', () => {
+ const result = monitor.check({ heapUsed: 930, heapLimit: 1000 });
+ expect(result).toBe('emergency');
+ });
+ });
+
+ describe('callback invocation', () => {
+ it('should call warning callback', () => {
+ const warningFn = jest.fn();
+ monitor.setCallbacks(warningFn, jest.fn(), jest.fn());
+
+ monitor.check({ heapUsed: 760, heapLimit: 1000 });
+ expect(warningFn).toHaveBeenCalled();
+ });
+
+ it('should call critical callback', () => {
+ const criticalFn = jest.fn();
+ monitor.setCallbacks(jest.fn(), criticalFn, jest.fn());
+
+ monitor.check({ heapUsed: 860, heapLimit: 1000 });
+ expect(criticalFn).toHaveBeenCalled();
+ });
+
+ it('should call emergency callback only once', () => {
+ const emergencyFn = jest.fn();
+ monitor.setCallbacks(jest.fn(), jest.fn(), emergencyFn);
+
+ monitor.check({ heapUsed: 930, heapLimit: 1000 });
+ monitor.check({ heapUsed: 940, heapLimit: 1000 });
+ monitor.check({ heapUsed: 950, heapLimit: 1000 });
+
+ expect(emergencyFn).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('emergency prevention', () => {
+ it('should prevent duplicate emergency triggers', () => {
+ monitor.check({ heapUsed: 930, heapLimit: 1000 });
+ expect(monitor.isEmergencyTriggered).toBe(true);
+
+ const result = monitor.check({ heapUsed: 940, heapLimit: 1000 });
+ expect(result).toBe('emergency-blocked');
+ });
+
+ it('should allow emergency after reset', () => {
+ monitor.check({ heapUsed: 930, heapLimit: 1000 });
+ monitor.reset();
+ expect(monitor.isEmergencyTriggered).toBe(false);
+
+ const result = monitor.check({ heapUsed: 930, heapLimit: 1000 });
+ expect(result).toBe('emergency');
+ });
+ });
+
+ describe('pressure tracking', () => {
+ it('should reset consecutive pressure on normal usage', () => {
+ // First trigger critical
+ monitor.check({ heapUsed: 860, heapLimit: 1000 });
+ // Then normal
+ monitor.check({ heapUsed: 500, heapLimit: 1000 });
+ // The counter should reset (validated by no crash and normal return)
+ const result = monitor.check({ heapUsed: 500, heapLimit: 1000 });
+ expect(result).toBeNull();
+ });
+ });
+});
+
+describe('Metrics bounded map logic', () => {
+ // Testing the bounded map pattern used for metrics
+
+ function incrementBoundedMap(
+ map: Map,
+ key: string,
+ maxSize: number
+ ): void {
+ map.set(key, (map.get(key) || 0) + 1);
+
+ // Evict oldest entries if over limit
+ if (map.size > maxSize) {
+ const firstKey = map.keys().next().value;
+ if (firstKey !== undefined) {
+ map.delete(firstKey);
+ }
+ }
+ }
+
+ it('should increment existing keys', () => {
+ const map = new Map();
+ incrementBoundedMap(map, 'key1', 10);
+ incrementBoundedMap(map, 'key1', 10);
+ expect(map.get('key1')).toBe(2);
+ });
+
+ it('should initialize new keys to 1', () => {
+ const map = new Map();
+ incrementBoundedMap(map, 'newKey', 10);
+ expect(map.get('newKey')).toBe(1);
+ });
+
+ it('should evict oldest when over limit', () => {
+ const map = new Map();
+ const maxSize = 3;
+
+ incrementBoundedMap(map, 'first', maxSize);
+ incrementBoundedMap(map, 'second', maxSize);
+ incrementBoundedMap(map, 'third', maxSize);
+ incrementBoundedMap(map, 'fourth', maxSize);
+
+ expect(map.size).toBe(3);
+ expect(map.has('first')).toBe(false);
+ expect(map.has('fourth')).toBe(true);
+ });
+
+ it('should maintain max size with many entries', () => {
+ const map = new Map();
+ const maxSize = 5;
+
+ for (let i = 0; i < 100; i++) {
+ incrementBoundedMap(map, `key${i}`, maxSize);
+ }
+
+ expect(map.size).toBeLessThanOrEqual(maxSize);
+ });
+});
diff --git a/src/api/controllers/plugin.rs b/src/api/controllers/plugin.rs
index 5fe12937c..8271ff802 100644
--- a/src/api/controllers/plugin.rs
+++ b/src/api/controllers/plugin.rs
@@ -2,12 +2,14 @@
//!
//! Handles HTTP endpoints for plugin operations including:
//! - Calling plugins
+//! - Listing plugins
+//! - Updating plugin configuration
use crate::{
jobs::JobProducerTrait,
models::{
ApiError, ApiResponse, NetworkRepoModel, NotificationRepoModel, PaginationMeta,
- PaginationQuery, PluginCallRequest, PluginModel, RelayerRepoModel, SignerRepoModel,
- ThinDataAppState, TransactionRepoModel,
+ PaginationQuery, PluginCallRequest, PluginModel, PluginValidationError, RelayerRepoModel,
+ SignerRepoModel, ThinDataAppState, TransactionRepoModel, UpdatePluginRequest,
},
repositories::{
ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
@@ -166,6 +168,91 @@ where
)))
}
+/// Get plugin by ID
+///
+/// # Arguments
+///
+/// * `plugin_id` - The ID of the plugin to retrieve.
+/// * `state` - The application state containing the plugin repository.
+///
+/// # Returns
+///
+/// The plugin model if found.
+pub async fn get_plugin(
+ plugin_id: String,
+ state: ThinDataAppState,
+) -> Result
+where
+ J: JobProducerTrait + Send + Sync + 'static,
+ RR: RelayerRepository + Repository + Send + Sync + 'static,
+ TR: TransactionRepository + Repository + Send + Sync + 'static,
+ NR: NetworkRepository + Repository + Send + Sync + 'static,
+ NFR: Repository + Send + Sync + 'static,
+ SR: Repository + Send + Sync + 'static,
+ TCR: TransactionCounterTrait + Send + Sync + 'static,
+ PR: PluginRepositoryTrait + Send + Sync + 'static,
+ AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
+{
+ let plugin = state
+ .plugin_repository
+ .get_by_id(&plugin_id)
+ .await?
+ .ok_or_else(|| ApiError::NotFound(format!("Plugin with id {plugin_id} not found")))?;
+
+ Ok(HttpResponse::Ok().json(ApiResponse::success(plugin)))
+}
+
+/// Update plugin configuration
+///
+/// Updates mutable plugin fields such as timeout, emit_logs, emit_traces,
+/// raw_response, allow_get_invocation, config, and forward_logs.
+/// The plugin id and path cannot be changed after creation.
+///
+/// # Arguments
+///
+/// * `plugin_id` - The ID of the plugin to update.
+/// * `update_request` - The update request containing the fields to update.
+/// * `state` - The application state containing the plugin repository.
+///
+/// # Returns
+///
+/// The updated plugin model.
+pub async fn update_plugin(
+ plugin_id: String,
+ update_request: UpdatePluginRequest,
+ state: ThinDataAppState,
+) -> Result
+where
+ J: JobProducerTrait + Send + Sync + 'static,
+ RR: RelayerRepository + Repository + Send + Sync + 'static,
+ TR: TransactionRepository + Repository + Send + Sync + 'static,
+ NR: NetworkRepository + Repository + Send + Sync + 'static,
+ NFR: Repository + Send + Sync + 'static,
+ SR: Repository + Send + Sync + 'static,
+ TCR: TransactionCounterTrait + Send + Sync + 'static,
+ PR: PluginRepositoryTrait + Send + Sync + 'static,
+ AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
+{
+ // Get existing plugin
+ let plugin = state
+ .plugin_repository
+ .get_by_id(&plugin_id)
+ .await?
+ .ok_or_else(|| ApiError::NotFound(format!("Plugin with id {plugin_id} not found")))?;
+
+ // Apply updates
+ let updated_plugin = plugin.apply_update(update_request).map_err(|e| match e {
+ PluginValidationError::InvalidTimeout(msg) => ApiError::BadRequest(msg),
+ })?;
+
+ // Save the updated plugin
+ let saved_plugin = state.plugin_repository.update(updated_plugin).await?;
+
+ tracing::info!(plugin_id = %plugin_id, "Plugin configuration updated");
+
+ Ok(HttpResponse::Ok().json(ApiResponse::success(saved_plugin)))
+}
+
#[cfg(test)]
mod tests {
use std::time::Duration;
@@ -324,6 +411,42 @@ mod tests {
assert_eq!(http_response.status(), StatusCode::OK);
}
+ #[actix_web::test]
+ async fn test_get_plugin_success() {
+ // Tests getting a plugin by ID
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS),
+ emit_logs: true,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: true,
+ config: None,
+ forward_logs: true,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ let response = get_plugin("test-plugin".to_string(), web::ThinData(app_state)).await;
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
+
+ #[actix_web::test]
+ async fn test_get_plugin_not_found() {
+ // Tests getting a non-existent plugin
+ let app_state = create_mock_app_state(None, None, None, None, None, None).await;
+
+ let response = get_plugin("non-existent".to_string(), web::ThinData(app_state)).await;
+ assert!(response.is_err());
+ match response.unwrap_err() {
+ ApiError::NotFound(msg) => assert!(msg.contains("non-existent")),
+ _ => panic!("Expected NotFound error"),
+ }
+ }
+
#[actix_web::test]
async fn test_call_plugin_with_raw_response() {
// Tests that raw_response flag returns plugin result directly
@@ -567,4 +690,254 @@ mod tests {
assert!(response.metadata.is_none());
assert!(response.error.is_none());
}
+
+ // ============================================================================
+ // UPDATE PLUGIN CONTROLLER TESTS
+ // ============================================================================
+
+ #[actix_web::test]
+ async fn test_update_plugin_success() {
+ // Tests successful plugin update
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: false,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: None,
+ forward_logs: false,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ let update_request = UpdatePluginRequest {
+ timeout: Some(60),
+ emit_logs: Some(true),
+ forward_logs: Some(true),
+ ..Default::default()
+ };
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_not_found() {
+ // Tests update on non-existent plugin
+ let app_state = create_mock_app_state(None, None, None, None, None, None).await;
+
+ let update_request = UpdatePluginRequest {
+ timeout: Some(60),
+ ..Default::default()
+ };
+
+ let response = update_plugin(
+ "non-existent".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_err());
+ match response.unwrap_err() {
+ ApiError::NotFound(msg) => assert!(msg.contains("non-existent")),
+ _ => panic!("Expected NotFound error"),
+ }
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_invalid_timeout() {
+ // Tests that timeout=0 returns BadRequest
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: false,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: None,
+ forward_logs: false,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ let update_request = UpdatePluginRequest {
+ timeout: Some(0), // Invalid: timeout must be > 0
+ ..Default::default()
+ };
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_err());
+ match response.unwrap_err() {
+ ApiError::BadRequest(msg) => assert!(msg.contains("Timeout")),
+ _ => panic!("Expected BadRequest error"),
+ }
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_with_config() {
+ // Tests updating plugin config
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: false,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: None,
+ forward_logs: false,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ let mut config_map = serde_json::Map::new();
+ config_map.insert("feature_flag".to_string(), serde_json::json!(true));
+ config_map.insert("api_key".to_string(), serde_json::json!("secret123"));
+
+ let update_request = UpdatePluginRequest {
+ config: Some(Some(config_map)),
+ ..Default::default()
+ };
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_clear_config() {
+ // Tests clearing plugin config by setting it to null
+ let mut initial_config = serde_json::Map::new();
+ initial_config.insert("existing".to_string(), serde_json::json!("value"));
+
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: false,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: Some(initial_config),
+ forward_logs: false,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ // Setting config to Some(None) should clear it
+ let update_request = UpdatePluginRequest {
+ config: Some(None),
+ ..Default::default()
+ };
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_all_fields() {
+ // Tests updating all mutable fields at once
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: false,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: None,
+ forward_logs: false,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ let mut config_map = serde_json::Map::new();
+ config_map.insert("key".to_string(), serde_json::json!("value"));
+
+ let update_request = UpdatePluginRequest {
+ timeout: Some(120),
+ emit_logs: Some(true),
+ emit_traces: Some(true),
+ raw_response: Some(true),
+ allow_get_invocation: Some(true),
+ config: Some(Some(config_map)),
+ forward_logs: Some(true),
+ };
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_empty_request() {
+ // Tests that an empty update request doesn't change anything (no-op)
+ let plugin = PluginModel {
+ id: "test-plugin".to_string(),
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: true,
+ emit_traces: true,
+ raw_response: false,
+ allow_get_invocation: false,
+ config: None,
+ forward_logs: true,
+ };
+ let app_state =
+ create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
+
+ // Empty update request - all fields are None
+ let update_request = UpdatePluginRequest::default();
+
+ let response = update_plugin(
+ "test-plugin".to_string(),
+ update_request,
+ web::ThinData(app_state),
+ )
+ .await;
+
+ assert!(response.is_ok());
+ let http_response = response.unwrap();
+ assert_eq!(http_response.status(), StatusCode::OK);
+ }
}
diff --git a/src/api/routes/docs/plugin_docs.rs b/src/api/routes/docs/plugin_docs.rs
index 424f6f6fa..8b2db35ce 100644
--- a/src/api/routes/docs/plugin_docs.rs
+++ b/src/api/routes/docs/plugin_docs.rs
@@ -1,5 +1,5 @@
use crate::{
- models::{ApiResponse, PluginCallRequest, PluginModel},
+ models::{ApiResponse, PluginCallRequest, PluginModel, UpdatePluginRequest},
repositories::PaginatedResult,
services::plugins::PluginHandlerError,
};
@@ -277,3 +277,196 @@ fn doc_call_plugin_get() {}
)]
#[allow(dead_code)]
fn doc_list_plugins() {}
+
+/// Get plugin by ID.
+#[utoipa::path(
+ get,
+ path = "/api/v1/plugins/{plugin_id}",
+ tag = "Plugins",
+ operation_id = "getPlugin",
+ summary = "Get plugin by ID",
+ security(
+ ("bearer_auth" = [])
+ ),
+ params(
+ ("plugin_id" = String, Path, description = "The unique identifier of the plugin")
+ ),
+ responses(
+ (
+ status = 200,
+ description = "Plugin retrieved successfully",
+ body = ApiResponse,
+ example = json!({
+ "success": true,
+ "data": {
+ "id": "my-plugin",
+ "path": "plugins/my-plugin.ts",
+ "timeout": 30,
+ "emit_logs": false,
+ "emit_traces": false,
+ "raw_response": false,
+ "allow_get_invocation": false,
+ "config": {
+ "featureFlag": true
+ },
+ "forward_logs": false
+ },
+ "error": null
+ })
+ ),
+ (
+ status = 401,
+ description = "Unauthorized",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Unauthorized",
+ "data": null
+ })
+ ),
+ (
+ status = 404,
+ description = "Plugin not found",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Plugin with id my-plugin not found",
+ "data": null
+ })
+ ),
+ (
+ status = 429,
+ description = "Too Many Requests",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Too Many Requests",
+ "data": null
+ })
+ ),
+ (
+ status = 500,
+ description = "Internal server error",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Internal Server Error",
+ "data": null
+ })
+ )
+ )
+)]
+#[allow(dead_code)]
+fn doc_get_plugin() {}
+
+/// Update plugin configuration.
+///
+/// Updates mutable plugin fields such as timeout, emit_logs, emit_traces,
+/// raw_response, allow_get_invocation, config, and forward_logs.
+/// The plugin id and path cannot be changed after creation.
+///
+/// All fields are optional - only the provided fields will be updated.
+/// To clear the `config` field, pass `"config": null`.
+#[utoipa::path(
+ patch,
+ path = "/api/v1/plugins/{plugin_id}",
+ tag = "Plugins",
+ operation_id = "updatePlugin",
+ summary = "Update plugin configuration",
+ security(
+ ("bearer_auth" = [])
+ ),
+ params(
+ ("plugin_id" = String, Path, description = "The unique identifier of the plugin")
+ ),
+ request_body(
+ content = UpdatePluginRequest,
+ description = "Plugin configuration update. All fields are optional.",
+ example = json!({
+ "timeout": 60,
+ "emit_logs": true,
+ "forward_logs": true,
+ "config": {
+ "featureFlag": true,
+ "apiKey": "xyz123"
+ }
+ })
+ ),
+ responses(
+ (
+ status = 200,
+ description = "Plugin updated successfully",
+ body = ApiResponse,
+ example = json!({
+ "success": true,
+ "data": {
+ "id": "my-plugin",
+ "path": "plugins/my-plugin.ts",
+ "timeout": 60,
+ "emit_logs": true,
+ "emit_traces": false,
+ "raw_response": false,
+ "allow_get_invocation": false,
+ "config": {
+ "featureFlag": true,
+ "apiKey": "xyz123"
+ },
+ "forward_logs": true
+ },
+ "error": null
+ })
+ ),
+ (
+ status = 400,
+ description = "Bad Request (invalid timeout or other validation error)",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Timeout must be greater than 0",
+ "data": null
+ })
+ ),
+ (
+ status = 401,
+ description = "Unauthorized",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Unauthorized",
+ "data": null
+ })
+ ),
+ (
+ status = 404,
+ description = "Plugin not found",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Plugin with id my-plugin not found",
+ "data": null
+ })
+ ),
+ (
+ status = 429,
+ description = "Too Many Requests",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Too Many Requests",
+ "data": null
+ })
+ ),
+ (
+ status = 500,
+ description = "Internal server error",
+ body = ApiResponse,
+ example = json!({
+ "success": false,
+ "error": "Internal Server Error",
+ "data": null
+ })
+ )
+ )
+)]
+#[allow(dead_code)]
+fn doc_update_plugin() {}
diff --git a/src/api/routes/plugin.rs b/src/api/routes/plugin.rs
index 94a9a8310..f388787be 100644
--- a/src/api/routes/plugin.rs
+++ b/src/api/routes/plugin.rs
@@ -5,10 +5,13 @@ use std::collections::HashMap;
use crate::{
api::controllers::plugin,
- models::{ApiError, ApiResponse, DefaultAppState, PaginationQuery, PluginCallRequest},
+ models::{
+ ApiError, ApiResponse, DefaultAppState, PaginationQuery, PluginCallRequest,
+ UpdatePluginRequest,
+ },
repositories::PluginRepositoryTrait,
};
-use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
+use actix_web::{get, patch, post, web, HttpRequest, HttpResponse, Responder};
use url::form_urlencoded;
/// List plugins
@@ -60,11 +63,12 @@ fn extract_query_params(http_req: &HttpRequest) -> HashMap>
/// Resolves the effective route from path and query parameters.
/// Path route takes precedence; if empty, falls back to `route` query parameter.
fn resolve_route(path_route: &str, http_req: &HttpRequest) -> String {
+ // Early return to avoid unnecessary query parameter parsing when path_route is provided
if !path_route.is_empty() {
return path_route.to_string();
}
- // Check for route in query parameters
+ // Only parse query parameters when path_route is empty (lazy evaluation)
let query_params = extract_query_params(http_req);
query_params
.get("route")
@@ -175,11 +179,34 @@ async fn plugin_call_get(
plugin::call_plugin(plugin_id, plugin_call_request, data).await
}
+/// Get plugin by ID
+#[get("/plugins/{plugin_id}")]
+async fn get_plugin(
+ path: web::Path,
+ data: web::ThinData,
+) -> impl Responder {
+ let plugin_id = path.into_inner();
+ plugin::get_plugin(plugin_id, data).await
+}
+
+/// Update plugin configuration
+#[patch("/plugins/{plugin_id}")]
+async fn update_plugin(
+ path: web::Path,
+ body: web::Json,
+ data: web::ThinData,
+) -> impl Responder {
+ let plugin_id = path.into_inner();
+ plugin::update_plugin(plugin_id, body.into_inner(), data).await
+}
+
/// Initializes the routes for the plugins module.
pub fn init(cfg: &mut web::ServiceConfig) {
// Register routes with literal segments before routes with path parameters
cfg.service(plugin_call); // POST /plugins/{plugin_id}/call
cfg.service(plugin_call_get); // GET /plugins/{plugin_id}/call
+ cfg.service(get_plugin); // GET /plugins/{plugin_id}
+ cfg.service(update_plugin); // PATCH /plugins/{plugin_id}
cfg.service(list_plugins); // GET /plugins
}
@@ -1761,4 +1788,326 @@ mod tests {
resp.status()
);
}
+
+ // ============================================================================
+ // GET PLUGIN ROUTE TESTS
+ // ============================================================================
+
+ /// Mock handler for get plugin that returns a plugin by ID
+ async fn mock_get_plugin(path: web::Path) -> impl Responder {
+ let plugin_id = path.into_inner();
+
+ if plugin_id == "not-found" {
+ return HttpResponse::NotFound().json(ApiResponse::<()>::error(format!(
+ "Plugin with id {} not found",
+ plugin_id
+ )));
+ }
+
+ let plugin = PluginModel {
+ id: plugin_id,
+ path: "test-path.ts".to_string(),
+ timeout: Duration::from_secs(30),
+ emit_logs: true,
+ emit_traces: false,
+ raw_response: false,
+ allow_get_invocation: true,
+ config: None,
+ forward_logs: true,
+ };
+
+ HttpResponse::Ok().json(ApiResponse::success(plugin))
+ }
+
+ #[actix_web::test]
+ async fn test_get_plugin_route_success() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::get()
+ .uri("/plugins/my-plugin")
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+
+ let body = test::read_body(resp).await;
+ let response: ApiResponse = serde_json::from_slice(&body).unwrap();
+ assert!(response.success);
+ assert_eq!(response.data.as_ref().unwrap().id, "my-plugin");
+ assert!(response.data.as_ref().unwrap().emit_logs);
+ assert!(response.data.as_ref().unwrap().forward_logs);
+ }
+
+ #[actix_web::test]
+ async fn test_get_plugin_route_not_found() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::get()
+ .uri("/plugins/not-found")
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
+ }
+
+ // ============================================================================
+ // UPDATE PLUGIN ROUTE TESTS
+ // ============================================================================
+
+ /// Mock handler for update plugin that returns the updated plugin
+ async fn mock_update_plugin(
+ path: web::Path,
+ body: web::Json,
+ ) -> impl Responder {
+ let plugin_id = path.into_inner();
+ let update = body.into_inner();
+
+ // Simulate successful update
+ let updated_plugin = PluginModel {
+ id: plugin_id,
+ path: "test-path".to_string(),
+ timeout: Duration::from_secs(update.timeout.unwrap_or(30)),
+ emit_logs: update.emit_logs.unwrap_or(false),
+ emit_traces: update.emit_traces.unwrap_or(false),
+ raw_response: update.raw_response.unwrap_or(false),
+ allow_get_invocation: update.allow_get_invocation.unwrap_or(false),
+ config: update.config.flatten(),
+ forward_logs: update.forward_logs.unwrap_or(false),
+ };
+
+ HttpResponse::Ok().json(ApiResponse::success(updated_plugin))
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_success() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::patch()
+ .uri("/plugins/test-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({
+ "timeout": 60,
+ "emit_logs": true,
+ "forward_logs": true
+ }))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+
+ let body = test::read_body(resp).await;
+ let response: ApiResponse = serde_json::from_slice(&body).unwrap();
+ assert!(response.success);
+ assert_eq!(response.data.as_ref().unwrap().id, "test-plugin");
+ assert_eq!(
+ response.data.as_ref().unwrap().timeout,
+ Duration::from_secs(60)
+ );
+ assert!(response.data.as_ref().unwrap().emit_logs);
+ assert!(response.data.as_ref().unwrap().forward_logs);
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_with_config() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::patch()
+ .uri("/plugins/my-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({
+ "config": {
+ "feature_enabled": true,
+ "api_key": "secret123"
+ }
+ }))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+
+ let body = test::read_body(resp).await;
+ let response: ApiResponse = serde_json::from_slice(&body).unwrap();
+ assert!(response.success);
+ assert!(response.data.as_ref().unwrap().config.is_some());
+ let config = response.data.as_ref().unwrap().config.as_ref().unwrap();
+ assert_eq!(
+ config.get("feature_enabled"),
+ Some(&serde_json::json!(true))
+ );
+ assert_eq!(config.get("api_key"), Some(&serde_json::json!("secret123")));
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_clear_config() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::patch()
+ .uri("/plugins/test-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({
+ "config": null
+ }))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+
+ let body = test::read_body(resp).await;
+ let response: ApiResponse = serde_json::from_slice(&body).unwrap();
+ assert!(response.success);
+ assert!(response.data.as_ref().unwrap().config.is_none());
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_empty_body() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ // Empty JSON object - no fields to update
+ let req = test::TestRequest::patch()
+ .uri("/plugins/test-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({}))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_all_fields() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::patch()
+ .uri("/plugins/full-update-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({
+ "timeout": 120,
+ "emit_logs": true,
+ "emit_traces": true,
+ "raw_response": true,
+ "allow_get_invocation": true,
+ "forward_logs": true,
+ "config": {
+ "key": "value"
+ }
+ }))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_success());
+
+ let body = test::read_body(resp).await;
+ let response: ApiResponse = serde_json::from_slice(&body).unwrap();
+ let plugin = response.data.unwrap();
+
+ assert_eq!(plugin.timeout, Duration::from_secs(120));
+ assert!(plugin.emit_logs);
+ assert!(plugin.emit_traces);
+ assert!(plugin.raw_response);
+ assert!(plugin.allow_get_invocation);
+ assert!(plugin.forward_logs);
+ assert!(plugin.config.is_some());
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_invalid_json() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ let req = test::TestRequest::patch()
+ .uri("/plugins/test-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_payload("{ invalid json }")
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_client_error());
+ }
+
+ #[actix_web::test]
+ async fn test_update_plugin_route_unknown_field_rejected() {
+ let app = test::init_service(
+ App::new()
+ .service(
+ web::resource("/plugins/{plugin_id}")
+ .route(web::patch().to(mock_update_plugin)),
+ )
+ .configure(init),
+ )
+ .await;
+
+ // UpdatePluginRequest has deny_unknown_fields, so this should fail
+ let req = test::TestRequest::patch()
+ .uri("/plugins/test-plugin")
+ .insert_header(("Content-Type", "application/json"))
+ .set_json(serde_json::json!({
+ "timeout": 60,
+ "unknown_field": "should_fail"
+ }))
+ .to_request();
+
+ let resp = test::call_service(&app, req).await;
+ assert!(resp.status().is_client_error());
+ }
}
diff --git a/src/bootstrap/initialize_plugins.rs b/src/bootstrap/initialize_plugins.rs
new file mode 100644
index 000000000..a7e7a8d05
--- /dev/null
+++ b/src/bootstrap/initialize_plugins.rs
@@ -0,0 +1,511 @@
+//! Plugin Pool Initialization
+//!
+//! This module handles conditional initialization of the plugin worker pool.
+//! The pool is only started if plugins are configured, avoiding overhead
+//! when the plugin system is not in use.
+
+use std::sync::Arc;
+use tracing::{info, warn};
+
+use crate::repositories::PluginRepositoryTrait;
+use crate::services::plugins::{get_pool_manager, PluginRunner, PluginService, PoolManager};
+
+/// Initialize the plugin worker pool if plugins are configured.
+///
+/// This function checks if any plugins are registered in the repository.
+/// If plugins exist, it starts the Piscina worker pool for efficient
+/// plugin execution. If no plugins are configured, it skips initialization.
+///
+/// # Arguments
+///
+/// * `plugin_repository` - Reference to the plugin repository
+///
+/// # Returns
+///
+/// * `Ok(Some(Arc))` - Pool manager if plugins are configured
+/// * `Ok(None)` - If no plugins are configured
+/// * `Err` - If pool initialization fails
+pub async fn initialize_plugin_pool(
+ plugin_repository: &PR,
+) -> eyre::Result