11mod js_runtime_config;
22mod op_proxy_request;
33mod script_termination_reason;
4+ mod worker_pool;
45
56pub use self :: {
67 js_runtime_config:: JsRuntimeConfig ,
78 op_proxy_request:: { ProxyState , PublicUrlValidator } ,
89} ;
9- use crate :: js_runtime:: script_termination_reason:: ScriptTerminationReason ;
10+ use crate :: js_runtime:: {
11+ script_termination_reason:: ScriptTerminationReason , worker_pool:: ScriptTask ,
12+ } ;
1013use anyhow:: Context ;
11- use deno_core:: { PollEventLoopOptions , RuntimeOptions , scope, serde_v8, v8} ;
14+ use deno_core:: { JsRuntimeForSnapshot , PollEventLoopOptions , RuntimeOptions , scope, serde_v8, v8} ;
1215use serde:: Deserialize ;
1316use std:: {
1417 sync:: {
15- Arc ,
18+ Arc , OnceLock ,
1619 atomic:: { AtomicBool , AtomicUsize , Ordering } ,
1720 } ,
1821 time:: { Duration , Instant } ,
1922} ;
23+ use tokio:: sync:: oneshot;
2024use tracing:: error;
2125
2226/// Defines a maximum interval on which script is checked for timeout.
2327const SCRIPT_TIMEOUT_CHECK_INTERVAL : Duration = Duration :: from_secs ( 2 ) ;
2428
2529deno_core:: extension!( secutils_ext, ops = [ op_proxy_request:: op_proxy_request] ) ;
2630
27- /// Wraps a user script in an async IIFE that auto-converts the `body` field
28- /// of the returned object: strings are UTF-8 encoded, objects/arrays are
29- /// JSON-serialized, and plain number arrays become `Uint8Array` for backward
30- /// compatibility. `Uint8Array`/`ArrayBuffer` values pass through unchanged.
31- pub fn wrap_script_with_body_conversion ( script : & str ) -> String {
31+ /// Cached V8 startup snapshot, built once on the main thread at `init_platform`
32+ /// time and reused by every subsequent `deno_core::JsRuntime::new` call across
33+ /// every worker. `Box::leak` gives us the `&'static [u8]` shape required by
34+ /// `RuntimeOptions::startup_snapshot`; the bytes live for the process lifetime,
35+ /// which is exactly what we want.
36+ static STARTUP_SNAPSHOT : OnceLock < & ' static [ u8 ] > = OnceLock :: new ( ) ;
37+
38+ fn build_startup_snapshot ( ) -> & ' static [ u8 ] {
39+ // The snapshot intentionally does not bake in `secutils_ext` or any JS
40+ // modules: baking ops into a snapshot requires the snapshotting runtime
41+ // to register the exact same op layout at runtime (a minefield of subtle
42+ // version/feature mismatches). What we capture here is the expensive
43+ // part - the V8 context setup and builtin JS globals. `secutils_ext` is
44+ // still registered at `JsRuntime::new` time per invocation, but on top
45+ // of a warm, pre-initialised context.
46+ let runtime = JsRuntimeForSnapshot :: new ( RuntimeOptions :: default ( ) ) ;
47+ let snapshot = runtime. snapshot ( ) ;
48+ Box :: leak ( snapshot) as & [ u8 ]
49+ }
50+
51+ fn startup_snapshot ( ) -> & ' static [ u8 ] {
52+ STARTUP_SNAPSHOT . get_or_init ( build_startup_snapshot)
53+ }
54+
55+ /// Wraps the raw user script in a minimal async IIFE so we can use
56+ /// `runtime.execute_script` + `runtime.resolve` uniformly whether the user
57+ /// returned a promise or a value.
58+ fn wrap_user_script_in_async_iife ( script : & str ) -> String {
3259 let script = script. trim ( ) . trim_end_matches ( ';' ) ;
33- format ! (
34- r#"(async (globalThis) => {{
35- const __result = await ({script});
36- if (__result && __result.body !== undefined && __result.body !== null) {{
37- const __body = __result.body;
38- if (__body instanceof Uint8Array || __body instanceof ArrayBuffer || ArrayBuffer.isView(__body)) {{
39- }} else if (typeof __body === 'string') {{
40- __result.body = Deno.core.encode(__body);
41- }} else if (Array.isArray(__body)) {{
42- if (__body.length === 0 || typeof __body[0] === 'number') {{
43- __result.body = new Uint8Array(__body);
44- }} else {{
45- __result.body = Deno.core.encode(JSON.stringify(__body));
46- }}
47- }} else {{
48- __result.body = Deno.core.encode(JSON.stringify(__body));
49- }}
50- }}
51- return __result;
52- }})(globalThis);"#
53- )
60+ format ! ( "(async () => (await ({script})))();" )
61+ }
62+
63+ /// Mutates `result` in place so that, if it is an object with a `body`
64+ /// property, the body value becomes a `Uint8Array` of bytes suitable for
65+ /// serde_v8 to deserialise into `Vec<u8>`.
66+ ///
67+ /// The conversion rules match what the legacy JS wrapper did:
68+ /// - `Uint8Array` / `ArrayBuffer` / typed-array views: passed through.
69+ /// - `string`: UTF-8 encoded.
70+ /// - numeric arrays (or empty arrays): copied into a `Uint8Array`.
71+ /// - non-numeric arrays and any other objects/primitives: `JSON.stringify`
72+ /// followed by UTF-8 encoding.
73+ ///
74+ /// `null`/`undefined` body values are left untouched so `Option<Vec<u8>>`
75+ /// fields can deserialise as `None`.
76+ fn normalize_response_body_in_place (
77+ scope : & mut v8:: PinScope < ' _ , ' _ > ,
78+ result : v8:: Local < v8:: Value > ,
79+ ) {
80+ let Ok ( obj) = v8:: Local :: < v8:: Object > :: try_from ( result) else {
81+ return ;
82+ } ;
83+ let Some ( body_key) = v8:: String :: new ( scope, "body" ) else {
84+ return ;
85+ } ;
86+ let body_key_v: v8:: Local < v8:: Value > = body_key. into ( ) ;
87+
88+ let Some ( body) = obj. get ( scope, body_key_v) else {
89+ return ;
90+ } ;
91+ if body. is_null_or_undefined ( ) {
92+ return ;
93+ }
94+ if body. is_uint8_array ( ) || body. is_array_buffer ( ) || body. is_array_buffer_view ( ) {
95+ return ;
96+ }
97+
98+ let replacement_bytes: Vec < u8 > = if body. is_string ( ) {
99+ v8:: Local :: < v8:: String > :: try_from ( body)
100+ . map ( |s| s. to_rust_string_lossy ( scope) . into_bytes ( ) )
101+ . unwrap_or_default ( )
102+ } else if body. is_array ( ) {
103+ let Ok ( arr) = v8:: Local :: < v8:: Array > :: try_from ( body) else {
104+ return ;
105+ } ;
106+ let len = arr. length ( ) ;
107+ if len == 0 {
108+ Vec :: new ( )
109+ } else {
110+ // The legacy JS picked the code path from the first element's type:
111+ // numeric first element -> treat every element as a u8 byte;
112+ // anything else -> JSON.stringify the whole array. Preserve that
113+ // exact semantic so behaviour is byte-identical to the old IIFE.
114+ let first = arr
115+ . get_index ( scope, 0 )
116+ . unwrap_or_else ( || v8:: undefined ( scope) . into ( ) ) ;
117+ if first. is_number ( ) {
118+ let mut bytes = Vec :: with_capacity ( len as usize ) ;
119+ for i in 0 ..len {
120+ let v = arr
121+ . get_index ( scope, i)
122+ . unwrap_or_else ( || v8:: undefined ( scope) . into ( ) ) ;
123+ let n = v. number_value ( scope) . unwrap_or ( 0.0 ) ;
124+ bytes. push ( n as u8 ) ;
125+ }
126+ bytes
127+ } else {
128+ match v8:: json:: stringify ( scope, body) {
129+ Some ( s) => s. to_rust_string_lossy ( scope) . into_bytes ( ) ,
130+ None => return ,
131+ }
132+ }
133+ }
134+ } else {
135+ match v8:: json:: stringify ( scope, body) {
136+ Some ( s) => s. to_rust_string_lossy ( scope) . into_bytes ( ) ,
137+ None => return ,
138+ }
139+ } ;
140+
141+ let len = replacement_bytes. len ( ) ;
142+ let backing = v8:: ArrayBuffer :: new_backing_store_from_vec ( replacement_bytes) . make_shared ( ) ;
143+ let buffer = v8:: ArrayBuffer :: with_backing_store ( scope, & backing) ;
144+ let Some ( typed) = v8:: Uint8Array :: new ( scope, buffer, 0 , len) else {
145+ return ;
146+ } ;
147+ obj. set ( scope, body_key_v, typed. into ( ) ) ;
54148}
55149
56150/// An abstraction over the V8/Deno runtime that allows any utilities to execute custom user
57- /// JavaScript scripts. Each invocation runs inside a dedicated `spawn_blocking` task with its own
58- /// `CurrentThread` tokio runtime so that async Deno ops (e.g. `op_proxy_request`) work correctly.
151+ /// JavaScript scripts. Script executions are dispatched to a process-wide pool of long-lived
152+ /// worker threads (see [`worker_pool`]), each owning its own persistent `CurrentThread` tokio
153+ /// runtime and `LocalSet`. A fresh V8 isolate is still created for every execution to preserve
154+ /// isolation between scripts; what we avoid is rebuilding the surrounding tokio machinery on
155+ /// every call.
59156pub struct JsRuntime ;
60157
61158impl JsRuntime {
62- /// Initializes the JS runtime platform, should be called only once and in the main thread.
159+ /// Initializes the JS runtime platform, builds the shared V8 startup
160+ /// snapshot, and eagerly spins up the worker pool. Should be called exactly
161+ /// once, from the main thread, during server startup.
63162 pub fn init_platform ( ) {
64163 deno_core:: JsRuntime :: init_platform ( None ) ;
164+ // Build the snapshot on the main thread before any worker boots so the
165+ // first script execution on each worker does not pay for it. V8 requires
166+ // the snapshotting isolate to run on a single thread, which is why we
167+ // do it here rather than lazily inside a worker.
168+ let _ = startup_snapshot ( ) ;
169+ worker_pool:: init ( ) ;
65170 }
66171
67- /// Executes a user script and returns the result. The script runs inside a `spawn_blocking`
68- /// task with its own `CurrentThread` tokio runtime and V8 isolate, providing full isolation
69- /// from other concurrent scripts and the main server.
172+ /// Executes a user script and returns the deserialised result.
173+ ///
174+ /// The script runs on one of the process-wide worker threads (round-robin scheduled),
175+ /// using that worker's long-lived tokio runtime and a fresh V8 isolate. This provides
176+ /// full isolation between scripts without paying the per-call cost of building a new
177+ /// tokio runtime every time.
178+ ///
179+ /// The raw user script is wrapped in a trivial async IIFE so callers can supply either
180+ /// a sync expression or a promise. After the script resolves, the returned object's
181+ /// `body` field (if present) is normalised in place to a `Uint8Array` - see
182+ /// [`normalize_response_body_in_place`] for the exact conversion rules. This keeps the
183+ /// responder code path (`Vec<u8>` bodies deserialised via `serde_bytes`) fast and
184+ /// avoids an async/await round-trip + JS conditional for every invocation.
70185 ///
71- /// `js_script_context` is an optional JSON string that will be parsed by V8's native JSON
72- /// parser and made available as the global `context` variable.
186+ /// `js_script_context` is an optional JSON string that will be parsed by V8's native
187+ /// JSON parser and made available as the global `context` variable.
73188 pub async fn execute_script < R : for < ' de > Deserialize < ' de > + Send + ' static > (
74189 config : JsRuntimeConfig ,
75190 js_code : String ,
76191 js_script_context : Option < String > ,
77192 proxy_state : Option < ProxyState > ,
78193 ) -> Result < ( R , Duration ) , anyhow:: Error > {
79- tokio:: task:: spawn_blocking ( move || {
80- let rt = tokio:: runtime:: Builder :: new_current_thread ( )
81- . enable_all ( )
82- . build ( )
83- . context ( "Failed to build CurrentThread tokio runtime for script execution" ) ?;
84- rt. block_on ( async {
85- Self :: execute_script_internal ( config, js_code, js_script_context, proxy_state) . await
194+ let wrapped = wrap_user_script_in_async_iife ( & js_code) ;
195+ let ( tx, rx) = oneshot:: channel :: < Result < ( R , Duration ) , anyhow:: Error > > ( ) ;
196+ let task = ScriptTask :: new ( move || {
197+ Box :: pin ( async move {
198+ let result = Self :: execute_script_internal :: < R > (
199+ config,
200+ wrapped,
201+ js_script_context,
202+ proxy_state,
203+ )
204+ . await ;
205+ // Ignore a dropped receiver: the caller awaiting `rx` either
206+ // got the result or gave up; either way, nothing to do.
207+ let _ = tx. send ( result) ;
86208 } )
87- } )
88- . await
89- . map_err ( |join_err| anyhow:: anyhow!( "Script execution task panicked: {join_err}" ) ) ?
209+ } ) ;
210+
211+ worker_pool:: global ( )
212+ . submit ( task)
213+ . map_err ( |_| anyhow:: anyhow!( "JS runtime worker pool unavailable" ) ) ?;
214+
215+ rx. await
216+ . map_err ( |_| anyhow:: anyhow!( "JS runtime worker dropped the script task" ) ) ?
90217 }
91218
92219 async fn execute_script_internal < R : for < ' de > Deserialize < ' de > > (
@@ -100,6 +227,7 @@ impl JsRuntime {
100227 let mut runtime = deno_core:: JsRuntime :: new ( RuntimeOptions {
101228 create_params : Some ( v8:: Isolate :: create_params ( ) . heap_limits ( 0 , config. max_heap_size ) ) ,
102229 extensions : vec ! [ secutils_ext:: init( ) ] ,
230+ startup_snapshot : Some ( startup_snapshot ( ) ) ,
103231 ..Default :: default ( )
104232 } ) ;
105233
@@ -224,6 +352,7 @@ impl JsRuntime {
224352 scope ! ( scope, runtime) ;
225353
226354 let local = v8:: Local :: new ( scope, script_result) ;
355+ normalize_response_body_in_place ( scope, local) ;
227356 serde_v8:: from_v8 ( scope, local)
228357 . map ( |result| ( result, now. elapsed ( ) ) )
229358 . with_context ( || "Error deserializing script result" )
@@ -549,9 +678,13 @@ pub mod tests {
549678 #[ serde( rename_all = "camelCase" ) ]
550679 struct ProxyResult {
551680 status_code : u16 ,
681+ #[ serde( with = "serde_bytes" ) ]
552682 body : Vec < u8 > ,
553683 }
554684
685+ // Script returns `body` as a plain JS number array. The runtime's body
686+ // normalisation converts it to a `Uint8Array` before serde_v8 sees it,
687+ // so `ProxyResult::body` deserialises via `serde_bytes`.
555688 let url = format ! ( "{}/data" , mock_server. base_url( ) ) ;
556689 let script = format ! (
557690 r#"(async () => {{
@@ -1374,9 +1507,7 @@ pub mod tests {
13741507 #[ cfg( feature = "bin-tests" ) ]
13751508 mod body_auto_convert {
13761509 use super :: * ;
1377- use crate :: {
1378- js_runtime:: wrap_script_with_body_conversion, utils:: webhooks:: ResponderScriptResult ,
1379- } ;
1510+ use crate :: utils:: webhooks:: ResponderScriptResult ;
13801511
13811512 fn config ( ) -> JsRuntimeConfig {
13821513 JsRuntimeConfig {
@@ -1388,7 +1519,7 @@ pub mod tests {
13881519 async fn run_script ( user_script : & str ) -> anyhow:: Result < ResponderScriptResult > {
13891520 let ( result, _) = JsRuntime :: execute_script :: < ResponderScriptResult > (
13901521 config ( ) ,
1391- wrap_script_with_body_conversion ( user_script) ,
1522+ user_script. to_string ( ) ,
13921523 None ,
13931524 None ,
13941525 )
0 commit comments