Skip to content

Commit dc915c8

Browse files
committed
Implement automatic JavaScript file reloading (issue #88)
This commit adds automatic reloading of JavaScript rules when using --js-file. Key features: - On-demand mtime checking (checked on each request) - Lock-free reads with ArcSwap for hot path performance - Singleflight pattern to prevent concurrent reloads - Validation before reload (keeps existing rules on error) - Comprehensive tests and documentation Implementation details: - Refactored evaluate() method into focused helper methods: - check_and_reload_file(): File monitoring and reload logic - load_js_code(): Lock-free code loading - execute_js_blocking(): V8 execution in blocking task - build_evaluation_result(): Result construction - Added module and struct documentation - Removed redundant create_and_execute() method - All tests pass (44 unit tests + 3 reload tests) Resolves #88
1 parent 42c46c9 commit dc915c8

File tree

9 files changed

+332
-47
lines changed

9 files changed

+332
-47
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ serde = { version = "1.0", features = ["derive"] }
4242
serde_json = "1.0"
4343
simple-dns = "0.7"
4444
tempfile = "3.8"
45+
arc-swap = "1.7"
4546

4647
[target.'cfg(target_os = "macos")'.dependencies]
4748
libc = "0.2"

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ Or download a pre-built binary from the [releases page](https://github.com/coder
3333
# Allow only requests to github.com (JS)
3434
httpjail --js "r.host === 'github.com'" -- your-app
3535

36-
# Load JS from a file
36+
# Load JS from a file (auto-reloads on file changes)
3737
echo "/^api\\.example\\.com$/.test(r.host) && r.method === 'GET'" > rules.js
3838
httpjail --js-file rules.js -- curl https://api.example.com/health
39+
# File changes are detected and reloaded automatically on each request
3940

4041
# Log requests to a file
4142
httpjail --request-log requests.log --js "true" -- npm install

docs/guide/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ httpjail follows a simple configuration hierarchy:
1616
Choose how requests are evaluated:
1717

1818
- **JavaScript** (`--js` or `--js-file`) - Fast, sandboxed evaluation
19+
- Files specified with `--js-file` are automatically reloaded when changed
1920
- **Shell Script** (`--sh`) - System integration, external tools
2021
- **Line Processor** (`--proc`) - Stateful, streaming evaluation
2122

docs/guide/quick-start.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ isAllowed && r.method !== 'DELETE';
8080
httpjail --js-file rules.js -- npm install
8181
```
8282

83+
> **Tip:** Rules files are automatically reloaded when they change - perfect for development and debugging! Just edit `rules.js` and the changes take effect on the next request.
84+
8385
### Request Logging
8486

8587
Monitor what requests are being made:

docs/guide/rule-engines/javascript.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ allowedHosts.includes(r.host);
4141
httpjail --js-file rules.js -- command
4242
```
4343

44+
#### Automatic File Reloading
45+
46+
When using `--js-file`, httpjail automatically detects and reloads the file when it changes. This is especially useful during development and debugging:
47+
48+
```bash
49+
# Start with initial rules
50+
echo "r.host === 'example.com'" > rules.js
51+
httpjail --js-file rules.js -- your-app
52+
53+
# In another terminal, update the rules (reloads automatically on next request)
54+
echo "r.host === 'github.com'" > rules.js
55+
```
56+
57+
**How it works:**
58+
- File modification time (mtime) is checked on each request
59+
- If the file has changed, it's reloaded and validated
60+
- Invalid JavaScript is rejected and existing rules are kept
61+
- Reload happens atomically without interrupting request processing
62+
- Zero overhead when the file hasn't changed
63+
64+
**Note:** File watching is only active when using `--js-file`. Inline rules (`--js`) do not reload.
65+
4466
## Response Format
4567

4668
{{#include ../../includes/response-format-table.md}}

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,8 @@ async fn main() -> Result<()> {
429429
info!("Using V8 JavaScript rule evaluation from file: {}", js_file);
430430
let code = std::fs::read_to_string(js_file)
431431
.with_context(|| format!("Failed to read JS file: {}", js_file))?;
432-
let js_engine = match V8JsRuleEngine::new(code) {
432+
let js_file_path = std::path::PathBuf::from(js_file);
433+
let js_engine = match V8JsRuleEngine::new_with_file(code, Some(js_file_path)) {
433434
Ok(engine) => Box::new(engine),
434435
Err(e) => {
435436
eprintln!("Failed to create V8 JavaScript engine: {}", e);

src/rules/v8_js.rs

Lines changed: 185 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
1+
//! V8 JavaScript rule engine implementation.
2+
//!
3+
//! This module provides a rule engine that evaluates HTTP requests using JavaScript
4+
//! code executed via the V8 engine. It supports automatic file reloading when rules
5+
//! are loaded from a file path.
6+
17
use crate::rules::common::{RequestInfo, RuleResponse};
28
use crate::rules::console_log;
39
use crate::rules::{EvaluationResult, RuleEngineTrait};
10+
use arc_swap::ArcSwap;
411
use async_trait::async_trait;
512
use hyper::Method;
13+
use std::path::PathBuf;
614
use std::sync::Arc;
15+
use std::time::SystemTime;
716
use tokio::sync::Mutex;
8-
use tracing::{debug, info, warn};
17+
use tracing::{debug, error, info, warn};
918

19+
/// V8-based JavaScript rule engine with automatic file reloading support.
20+
///
21+
/// The engine uses a lock-free ArcSwap for reading JavaScript code on every request,
22+
/// and employs a singleflight pattern (via Mutex) to prevent concurrent file reloads.
1023
pub struct V8JsRuleEngine {
11-
js_code: String,
12-
#[allow(dead_code)]
13-
runtime: Arc<Mutex<()>>, // Placeholder for V8 runtime management
24+
/// JavaScript code and its last modified time (lock-free atomic updates)
25+
js_code: ArcSwap<(String, Option<SystemTime>)>,
26+
/// Optional file path for automatic reloading
27+
js_file_path: Option<PathBuf>,
28+
/// Lock to prevent concurrent file reloads (singleflight pattern)
29+
reload_lock: Arc<Mutex<()>>,
1430
}
1531

1632
impl V8JsRuleEngine {
1733
pub fn new(js_code: String) -> Result<Self, Box<dyn std::error::Error>> {
34+
Self::new_with_file(js_code, None)
35+
}
36+
37+
pub fn new_with_file(
38+
js_code: String,
39+
js_file_path: Option<PathBuf>,
40+
) -> Result<Self, Box<dyn std::error::Error>> {
1841
// Initialize V8 platform once and keep it alive for the lifetime of the program
1942
use std::sync::OnceLock;
2043
static V8_PLATFORM: OnceLock<v8::SharedRef<v8::Platform>> = OnceLock::new();
@@ -27,27 +50,45 @@ impl V8JsRuleEngine {
2750
});
2851

2952
// Compile the JavaScript to check for syntax errors
30-
{
31-
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
32-
let handle_scope = &mut v8::HandleScope::new(&mut isolate);
33-
let context = v8::Context::new(handle_scope, Default::default());
34-
let context_scope = &mut v8::ContextScope::new(handle_scope, context);
53+
Self::validate_js_code(&js_code)?;
54+
55+
// Get initial mtime if file path is provided
56+
let initial_mtime = js_file_path
57+
.as_ref()
58+
.and_then(|path| std::fs::metadata(path).ok().and_then(|m| m.modified().ok()));
3559

36-
let source =
37-
v8::String::new(context_scope, &js_code).ok_or("Failed to create V8 string")?;
60+
let js_code_swap = ArcSwap::from(Arc::new((js_code, initial_mtime)));
3861

39-
v8::Script::compile(context_scope, source, None)
40-
.ok_or("Failed to compile JavaScript expression")?;
62+
if js_file_path.is_some() {
63+
info!("File watching enabled for JS rules - will check for changes on each request");
4164
}
4265

4366
info!("V8 JavaScript rule engine initialized");
4467
Ok(Self {
45-
js_code,
46-
runtime: Arc::new(Mutex::new(())),
68+
js_code: js_code_swap,
69+
js_file_path,
70+
reload_lock: Arc::new(Mutex::new(())),
4771
})
4872
}
4973

50-
pub fn execute(
74+
/// Validate JavaScript code by compiling it with V8
75+
fn validate_js_code(js_code: &str) -> Result<(), Box<dyn std::error::Error>> {
76+
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
77+
let handle_scope = &mut v8::HandleScope::new(&mut isolate);
78+
let context = v8::Context::new(handle_scope, Default::default());
79+
let context_scope = &mut v8::ContextScope::new(handle_scope, context);
80+
81+
let source = v8::String::new(context_scope, js_code).ok_or("Failed to create V8 string")?;
82+
83+
v8::Script::compile(context_scope, source, None)
84+
.ok_or("Failed to compile JavaScript expression")?;
85+
86+
Ok(())
87+
}
88+
89+
/// Execute JavaScript rules against a request (public API).
90+
/// For internal use, prefer calling `evaluate()` via the RuleEngineTrait.
91+
pub async fn execute(
5192
&self,
5293
method: &Method,
5394
url: &str,
@@ -61,7 +102,11 @@ impl V8JsRuleEngine {
61102
}
62103
};
63104

64-
match self.create_and_execute(&request_info) {
105+
// Load the current JS code (lock-free)
106+
let code_and_mtime = self.js_code.load();
107+
let (js_code, _) = &**code_and_mtime;
108+
109+
match Self::execute_with_code(js_code, &request_info) {
65110
Ok(result) => result,
66111
Err(e) => {
67112
warn!("JavaScript execution failed: {}", e);
@@ -196,57 +241,152 @@ impl V8JsRuleEngine {
196241
Ok((allowed, message, max_tx_bytes))
197242
}
198243

244+
/// Execute JavaScript code with a given code string (can be called from blocking context)
199245
#[allow(clippy::type_complexity)]
200-
fn create_and_execute(
201-
&self,
246+
fn execute_with_code(
247+
js_code: &str,
202248
request_info: &RequestInfo,
203249
) -> Result<(bool, Option<String>, Option<u64>), Box<dyn std::error::Error>> {
204250
// Create a new isolate for each execution (simpler approach)
205251
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
206-
Self::execute_with_isolate(&mut isolate, &self.js_code, request_info)
252+
Self::execute_with_isolate(&mut isolate, js_code, request_info)
207253
}
208-
}
209254

210-
#[async_trait]
211-
impl RuleEngineTrait for V8JsRuleEngine {
212-
async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult {
213-
// Run the JavaScript evaluation in a blocking task to avoid
214-
// issues with V8's single-threaded nature
255+
/// Check if the JS file has changed and reload if necessary.
256+
/// Uses double-check locking pattern to prevent concurrent reloads.
257+
async fn check_and_reload_file(&self) {
258+
let Some(ref path) = self.js_file_path else {
259+
return;
260+
};
261+
262+
let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
263+
264+
// Fast path: check if reload needed (no lock)
265+
let code_and_mtime = self.js_code.load();
266+
let (_, last_mtime) = &**code_and_mtime;
267+
268+
if current_mtime != *last_mtime && current_mtime.is_some() {
269+
// Slow path: acquire lock to prevent concurrent reloads (singleflight)
270+
let _guard = self.reload_lock.lock().await;
271+
272+
// Double-check: file might have been reloaded while waiting for lock
273+
let code_and_mtime = self.js_code.load();
274+
let (_, last_mtime) = &**code_and_mtime;
275+
276+
if current_mtime != *last_mtime && current_mtime.is_some() {
277+
info!("Detected change in JS rules file: {:?}", path);
278+
279+
// Re-read and validate the file
280+
match std::fs::read_to_string(path) {
281+
Ok(new_code) => {
282+
// Validate the new code before reloading
283+
if let Err(e) = Self::validate_js_code(&new_code) {
284+
error!(
285+
"Failed to validate updated JS code: {}. Keeping existing rules.",
286+
e
287+
);
288+
} else {
289+
// Update the code and mtime atomically (lock-free swap)
290+
self.js_code.store(Arc::new((new_code, current_mtime)));
291+
info!("Successfully reloaded JS rules from file");
292+
}
293+
}
294+
Err(e) => {
295+
error!(
296+
"Failed to read updated JS file: {}. Keeping existing rules.",
297+
e
298+
);
299+
}
300+
}
301+
}
302+
}
303+
}
304+
305+
/// Load the current JS code from the ArcSwap (lock-free operation).
306+
fn load_js_code(&self) -> String {
307+
let code_and_mtime = self.js_code.load();
308+
let (js_code, _) = &**code_and_mtime;
309+
js_code.clone()
310+
}
311+
312+
/// Execute JavaScript in a blocking task to handle V8's single-threaded nature.
313+
/// Returns (allowed, context, max_tx_bytes).
314+
async fn execute_js_blocking(
315+
js_code: String,
316+
method: Method,
317+
url: &str,
318+
requester_ip: &str,
319+
) -> (bool, Option<String>, Option<u64>) {
215320
let method_clone = method.clone();
216321
let url_clone = url.to_string();
217322
let ip_clone = requester_ip.to_string();
218323

219-
// Clone self to move into the closure
220-
let self_clone = Self {
221-
js_code: self.js_code.clone(),
222-
runtime: self.runtime.clone(),
223-
};
324+
tokio::task::spawn_blocking(move || {
325+
let request_info = match RequestInfo::from_request(&method_clone, &url_clone, &ip_clone)
326+
{
327+
Ok(info) => info,
328+
Err(e) => {
329+
warn!("Failed to parse request info: {}", e);
330+
return (false, Some("Invalid request format".to_string()), None);
331+
}
332+
};
224333

225-
let (allowed, context, max_tx_bytes) = tokio::task::spawn_blocking(move || {
226-
self_clone.execute(&method_clone, &url_clone, &ip_clone)
334+
match Self::execute_with_code(&js_code, &request_info) {
335+
Ok(result) => result,
336+
Err(e) => {
337+
warn!("JavaScript execution failed: {}", e);
338+
(false, Some("JavaScript execution failed".to_string()), None)
339+
}
340+
}
227341
})
228342
.await
229343
.unwrap_or_else(|e| {
230344
warn!("Failed to spawn V8 evaluation task: {}", e);
231345
(false, Some("Evaluation failed".to_string()), None)
232-
});
346+
})
347+
}
348+
349+
/// Build an EvaluationResult from the execution outcome.
350+
fn build_evaluation_result(
351+
allowed: bool,
352+
context: Option<String>,
353+
max_tx_bytes: Option<u64>,
354+
) -> EvaluationResult {
355+
let mut result = if allowed {
356+
EvaluationResult::allow()
357+
} else {
358+
EvaluationResult::deny()
359+
};
360+
361+
if let Some(ctx) = context {
362+
result = result.with_context(ctx);
363+
}
233364

234365
if allowed {
235-
let mut result = EvaluationResult::allow();
236-
if let Some(ctx) = context {
237-
result = result.with_context(ctx);
238-
}
239366
if let Some(bytes) = max_tx_bytes {
240367
result = result.with_max_tx_bytes(bytes);
241368
}
242-
result
243-
} else {
244-
let mut result = EvaluationResult::deny();
245-
if let Some(ctx) = context {
246-
result = result.with_context(ctx);
247-
}
248-
result
249369
}
370+
371+
result
372+
}
373+
}
374+
375+
#[async_trait]
376+
impl RuleEngineTrait for V8JsRuleEngine {
377+
async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult {
378+
// Check if file has changed and reload if necessary
379+
self.check_and_reload_file().await;
380+
381+
// Load the current JS code (lock-free operation)
382+
let js_code = self.load_js_code();
383+
384+
// Execute JavaScript in blocking task
385+
let (allowed, context, max_tx_bytes) =
386+
Self::execute_js_blocking(js_code, method, url, requester_ip).await;
387+
388+
// Build and return the result
389+
Self::build_evaluation_result(allowed, context, max_tx_bytes)
250390
}
251391

252392
fn name(&self) -> &str {

0 commit comments

Comments
 (0)