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+
17use crate :: rules:: common:: { RequestInfo , RuleResponse } ;
28use crate :: rules:: { EvaluationResult , RuleEngineTrait } ;
9+ use arc_swap:: ArcSwap ;
310use async_trait:: async_trait;
411use hyper:: Method ;
12+ use std:: path:: PathBuf ;
513use std:: sync:: Arc ;
14+ use std:: time:: SystemTime ;
615use 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.
922pub 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
1531impl 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