Skip to content

Commit 21d06a3

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 e496e4c commit 21d06a3

9 files changed

Lines changed: 332 additions & 47 deletions

File tree

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,19 +1,42 @@
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::{EvaluationResult, RuleEngineTrait};
9+
use arc_swap::ArcSwap;
310
use async_trait::async_trait;
411
use hyper::Method;
12+
use std::path::PathBuf;
513
use std::sync::Arc;
14+
use std::time::SystemTime;
615
use tokio::sync::Mutex;
7-
use tracing::{debug, info, warn};
16+
use tracing::{debug, error, info, warn};
817

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

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

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

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

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

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

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

63-
match self.create_and_execute(&request_info) {
104+
// Load the current JS code (lock-free)
105+
let code_and_mtime = self.js_code.load();
106+
let (js_code, _) = &**code_and_mtime;
107+
108+
match Self::execute_with_code(js_code, &request_info) {
64109
Ok(result) => result,
65110
Err(e) => {
66111
warn!("JavaScript execution failed: {}", e);
@@ -192,57 +237,152 @@ impl V8JsRuleEngine {
192237
Ok((allowed, message, max_tx_bytes))
193238
}
194239

240+
/// Execute JavaScript code with a given code string (can be called from blocking context)
195241
#[allow(clippy::type_complexity)]
196-
fn create_and_execute(
197-
&self,
242+
fn execute_with_code(
243+
js_code: &str,
198244
request_info: &RequestInfo,
199245
) -> Result<(bool, Option<String>, Option<u64>), Box<dyn std::error::Error>> {
200246
// Create a new isolate for each execution (simpler approach)
201247
let mut isolate = v8::Isolate::new(v8::CreateParams::default());
202-
Self::execute_with_isolate(&mut isolate, &self.js_code, request_info)
248+
Self::execute_with_isolate(&mut isolate, js_code, request_info)
203249
}
204-
}
205250

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

215-
// Clone self to move into the closure
216-
let self_clone = Self {
217-
js_code: self.js_code.clone(),
218-
runtime: self.runtime.clone(),
219-
};
320+
tokio::task::spawn_blocking(move || {
321+
let request_info = match RequestInfo::from_request(&method_clone, &url_clone, &ip_clone)
322+
{
323+
Ok(info) => info,
324+
Err(e) => {
325+
warn!("Failed to parse request info: {}", e);
326+
return (false, Some("Invalid request format".to_string()), None);
327+
}
328+
};
220329

221-
let (allowed, context, max_tx_bytes) = tokio::task::spawn_blocking(move || {
222-
self_clone.execute(&method_clone, &url_clone, &ip_clone)
330+
match Self::execute_with_code(&js_code, &request_info) {
331+
Ok(result) => result,
332+
Err(e) => {
333+
warn!("JavaScript execution failed: {}", e);
334+
(false, Some("JavaScript execution failed".to_string()), None)
335+
}
336+
}
223337
})
224338
.await
225339
.unwrap_or_else(|e| {
226340
warn!("Failed to spawn V8 evaluation task: {}", e);
227341
(false, Some("Evaluation failed".to_string()), None)
228-
});
342+
})
343+
}
344+
345+
/// Build an EvaluationResult from the execution outcome.
346+
fn build_evaluation_result(
347+
allowed: bool,
348+
context: Option<String>,
349+
max_tx_bytes: Option<u64>,
350+
) -> EvaluationResult {
351+
let mut result = if allowed {
352+
EvaluationResult::allow()
353+
} else {
354+
EvaluationResult::deny()
355+
};
356+
357+
if let Some(ctx) = context {
358+
result = result.with_context(ctx);
359+
}
229360

230361
if allowed {
231-
let mut result = EvaluationResult::allow();
232-
if let Some(ctx) = context {
233-
result = result.with_context(ctx);
234-
}
235362
if let Some(bytes) = max_tx_bytes {
236363
result = result.with_max_tx_bytes(bytes);
237364
}
238-
result
239-
} else {
240-
let mut result = EvaluationResult::deny();
241-
if let Some(ctx) = context {
242-
result = result.with_context(ctx);
243-
}
244-
result
245365
}
366+
367+
result
368+
}
369+
}
370+
371+
#[async_trait]
372+
impl RuleEngineTrait for V8JsRuleEngine {
373+
async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult {
374+
// Check if file has changed and reload if necessary
375+
self.check_and_reload_file().await;
376+
377+
// Load the current JS code (lock-free operation)
378+
let js_code = self.load_js_code();
379+
380+
// Execute JavaScript in blocking task
381+
let (allowed, context, max_tx_bytes) =
382+
Self::execute_js_blocking(js_code, method, url, requester_ip).await;
383+
384+
// Build and return the result
385+
Self::build_evaluation_result(allowed, context, max_tx_bytes)
246386
}
247387

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

0 commit comments

Comments
 (0)