diff --git a/examples/jsonplaceholder.graphql b/examples/jsonplaceholder.graphql index 028bc1b047..3c3f040128 100644 --- a/examples/jsonplaceholder.graphql +++ b/examples/jsonplaceholder.graphql @@ -1,13 +1,12 @@ schema - @server(port: 8000, headers: {cors: {allowOrigins: ["*"], allowHeaders: ["*"], allowMethods: [POST, GET, OPTIONS]}}) - @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: true, batch: {delay: 100}) { + @server(port: 8000) + @link(src: "scripts/echo.js", type: Script) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: true) { query: Query } type Query { posts: [Post] @http(path: "/posts") - users: [User] @http(path: "/users") - user(id: Int!): User @http(path: "/users/{{.args.id}}") } type User { @@ -24,5 +23,6 @@ type Post { userId: Int! title: String! body: String! - user: User @call(steps: [{query: "user", args: {id: "{{.value.userId}}"}}]) + curId: Int! @js(name: "hello") + # user: User @call(steps: [{query: "user", args: {id: "{{.value.userId}}"}}]) } diff --git a/examples/scripts/echo.js b/examples/scripts/echo.js index d09ec8d429..77cdcfdb38 100644 --- a/examples/scripts/echo.js +++ b/examples/scripts/echo.js @@ -1,3 +1,8 @@ function onRequest({request}) { return {request} } + +function hello(val) { + let json = JSON.parse(val) + return JSON.stringify(json.id) +} diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index 9b6705faeb..383e95ca5c 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -179,7 +179,7 @@ directive @http( ) on FIELD_DEFINITION directive @js( - script: String! + name: String! ) on FIELD_DEFINITION """ @@ -667,7 +667,7 @@ enum HttpVersion { HTTP2 } input JS { - script: String! + name: String! } input KeyValue { key: String! diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index a39e72e028..8e117c015c 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -749,10 +749,10 @@ "JS": { "type": "object", "required": [ - "script" + "name" ], "properties": { - "script": { + "name": { "type": "string" } } diff --git a/src/blueprint/definitions.rs b/src/blueprint/definitions.rs index 7a7fdb1991..19e5665add 100644 --- a/src/blueprint/definitions.rs +++ b/src/blueprint/definitions.rs @@ -500,6 +500,7 @@ pub fn to_field_definition( .and(update_http().trace(config::Http::trace_name().as_str())) .and(update_grpc(operation_type).trace(config::Grpc::trace_name().as_str())) .and(update_const_field().trace(config::Expr::trace_name().as_str())) + .and(update_js_field().trace(config::JS::trace_name().as_str())) .and(update_graphql(operation_type).trace(config::GraphQL::trace_name().as_str())) .and(update_modify().trace(config::Modify::trace_name().as_str())) .and(update_call(operation_type, object_name).trace(config::Call::trace_name().as_str())) diff --git a/src/blueprint/operators/js.rs b/src/blueprint/operators/js.rs new file mode 100644 index 0000000000..449b843fd2 --- /dev/null +++ b/src/blueprint/operators/js.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use crate::blueprint::*; +use crate::config; +use crate::config::Field; +use crate::javascript::Runtime; +use crate::lambda::Expression; +use crate::try_fold::TryFold; +use crate::valid::{Valid, ValidationError, Validator}; + +pub struct CompileJs<'a> { + pub name: &'a str, + pub script: &'a Option, +} + +pub fn compile_js(inputs: CompileJs) -> Valid { + let name = inputs.name; + let quickjs = rquickjs::Runtime::new().unwrap(); + let ctx = rquickjs::Context::full(&quickjs).unwrap(); + + Valid::from_option(inputs.script.as_ref(), "script is required".to_string()).and_then( + |script| { + Valid::from( + ctx.with(|ctx| { + ctx.eval::<(), &str>(script)?; + Ok::<_, anyhow::Error>(()) + }) + .map_err(|e| ValidationError::new(e.to_string())), + ) + .map(|_| { + Expression::Js(Arc::new(Runtime::new( + name.to_string(), + Script { source: script.clone(), timeout: None }, + ))) + }) + }, + ) +} + +pub fn update_js_field<'a>( +) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> +{ + TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( + |(module, field, _, _), b_field| { + let Some(js) = &field.script else { + return Valid::succeed(b_field); + }; + + compile_js(CompileJs { script: &module.extensions.script, name: &js.name }) + .map(|resolver| b_field.resolver(Some(resolver))) + }, + ) +} diff --git a/src/blueprint/operators/mod.rs b/src/blueprint/operators/mod.rs index f12e64780e..166d71f219 100644 --- a/src/blueprint/operators/mod.rs +++ b/src/blueprint/operators/mod.rs @@ -3,6 +3,7 @@ mod expr; mod graphql; mod grpc; mod http; +mod js; mod modify; mod protected; @@ -11,5 +12,6 @@ pub use expr::*; pub use graphql::*; pub use grpc::*; pub use http::*; +pub use js::*; pub use modify::*; pub use protected::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 497016fab9..bc3c987b93 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,6 @@ mod command; mod error; mod fmt; -#[cfg(feature = "js")] -pub mod javascript; pub mod metrics; pub mod server; mod tc; diff --git a/src/cli/runtime/mod.rs b/src/cli/runtime/mod.rs index b0499c6f35..6852d5ce39 100644 --- a/src/cli/runtime/mod.rs +++ b/src/cli/runtime/mod.rs @@ -23,7 +23,7 @@ fn init_file() -> Arc { fn init_hook_http(http: Arc, script: Option) -> Arc { #[cfg(feature = "js")] if let Some(script) = script { - return crate::cli::javascript::init_http(http, script); + return crate::javascript::init_http(http, script); } #[cfg(not(feature = "js"))] diff --git a/src/config/config.rs b/src/config/config.rs index a2478f37e7..d863224f06 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -355,7 +355,7 @@ impl Field { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, schemars::JsonSchema)] pub struct JS { - pub script: String, + pub name: String, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, schemars::JsonSchema)] diff --git a/src/cli/javascript/js_request.rs b/src/javascript/js_request.rs similarity index 100% rename from src/cli/javascript/js_request.rs rename to src/javascript/js_request.rs diff --git a/src/cli/javascript/js_response.rs b/src/javascript/js_response.rs similarity index 100% rename from src/cli/javascript/js_response.rs rename to src/javascript/js_response.rs diff --git a/src/cli/javascript/mod.rs b/src/javascript/mod.rs similarity index 92% rename from src/cli/javascript/mod.rs rename to src/javascript/mod.rs index 8ecbb6aa40..90859b5f13 100644 --- a/src/cli/javascript/mod.rs +++ b/src/javascript/mod.rs @@ -20,7 +20,7 @@ pub fn init_http( script: blueprint::Script, ) -> Arc { tracing::debug!("Initializing JavaScript HTTP filter: {}", script.source); - let script_io = Arc::new(Runtime::new(script)); + let script_io = Arc::new(Runtime::new("onRequest".to_string(), script)); Arc::new(RequestFilter::new(http, script_io)) } diff --git a/src/cli/javascript/request_filter.rs b/src/javascript/request_filter.rs similarity index 96% rename from src/cli/javascript/request_filter.rs rename to src/javascript/request_filter.rs index 5cb6b653d1..1bc7c04b51 100644 --- a/src/cli/javascript/request_filter.rs +++ b/src/javascript/request_filter.rs @@ -63,7 +63,7 @@ impl RequestFilter { async fn on_request(&self, mut request: reqwest::Request) -> anyhow::Result> { let js_request = JsRequest::try_from(&request)?; let event = Event::Request(js_request); - let command = self.worker.call("onRequest".to_string(), event).await?; + let command = self.worker.call(event).await?; match command { Some(command) => match command { Command::Request(js_request) => { @@ -104,9 +104,9 @@ mod tests { use hyper::body::Bytes; use rquickjs::{Context, FromJs, IntoJs, Object, Runtime, String as JsString}; - use crate::cli::javascript::request_filter::Command; - use crate::cli::javascript::{JsRequest, JsResponse}; use crate::http::Response; + use crate::javascript::request_filter::Command; + use crate::javascript::{JsRequest, JsResponse}; #[test] fn test_command_from_invalid_object() { diff --git a/src/cli/javascript/runtime.rs b/src/javascript/runtime.rs similarity index 55% rename from src/cli/javascript/runtime.rs rename to src/javascript/runtime.rs index 3f2c5cf7b0..a5f9339371 100644 --- a/src/cli/javascript/runtime.rs +++ b/src/javascript/runtime.rs @@ -1,6 +1,8 @@ use std::cell::{OnceCell, RefCell}; +use std::fmt::{Debug, Formatter}; use std::thread; +use async_graphql_value::ConstValue; use rquickjs::{Context, Ctx, FromJs, Function, IntoJs, Value}; use super::request_filter::{Command, Event}; @@ -25,7 +27,7 @@ fn qjs_print(msg: String, is_err: bool) { fn setup_builtins(ctx: &Ctx<'_>) -> rquickjs::Result<()> { ctx.globals().set("__qjs_print", js_qjs_print)?; - let _: Value = ctx.eval_file("src/cli/javascript/shim/console.js")?; + let _: Value = ctx.eval_file("src/javascript/shim/console.js")?; Ok(()) } @@ -47,18 +49,33 @@ impl LocalRuntime { pub struct Runtime { script: blueprint::Script, + function_name: String, // Single threaded JS runtime, that's shared across all tokio workers. tokio_runtime: Option, } +impl Debug for Runtime { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Runtime {{ script: {:?}, function_name: {} }}", + self.script, self.function_name + ) + } +} + impl Runtime { - pub fn new(script: blueprint::Script) -> Self { + pub fn new(name: String, script: blueprint::Script) -> Self { let tokio_runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .build() .expect("JS runtime not initialized"); - Self { script, tokio_runtime: Some(tokio_runtime) } + Self { + script, + function_name: name, + tokio_runtime: Some(tokio_runtime), + } } } @@ -75,27 +92,13 @@ impl Drop for Runtime { #[async_trait::async_trait] impl WorkerIO for Runtime { - async fn call(&self, name: String, event: Event) -> anyhow::Result> { + async fn call(&self, event: Event) -> anyhow::Result> { let script = self.script.clone(); + let name = self.function_name.clone(); if let Some(runtime) = &self.tokio_runtime { runtime .spawn(async move { - // initialize runtime if this is the first call - // exit if failed to initialize - LOCAL_RUNTIME.with(move |cell| { - if cell.borrow().get().is_none() { - LocalRuntime::try_new(script).and_then(|runtime| { - cell.borrow().set(runtime).map_err(|_| { - anyhow::anyhow!( - "trying to reinitialize an already initialized QuickJS runtime" - ) - }) - }) - } else { - Ok(()) - } - })?; - + init_rt(script)?; call(name, event) }) .await? @@ -105,6 +108,40 @@ impl WorkerIO for Runtime { } } +#[async_trait::async_trait] +impl WorkerIO, ConstValue> for Runtime { + async fn call(&self, input: Option) -> anyhow::Result> { + let script = self.script.clone(); + let name = self.function_name.clone(); + if let Some(runtime) = &self.tokio_runtime { + runtime + .spawn(async move { + init_rt(script)?; + execute_inner(name, input).map(Some) + }) + .await? + } else { + anyhow::bail!("JS Runtime is stopped") + } + } +} + +fn init_rt(script: blueprint::Script) -> anyhow::Result<()> { + // initialize runtime if this is the first call + // exit if failed to initialize + LOCAL_RUNTIME.with(move |cell| { + if cell.borrow().get().is_none() { + LocalRuntime::try_new(script).and_then(|runtime| { + cell.borrow().set(runtime).map_err(|_| { + anyhow::anyhow!("trying to reinitialize an already initialized QuickJS runtime") + }) + }) + } else { + Ok(()) + } + }) +} + fn prepare_args<'js>(ctx: &Ctx<'js>, req: JsRequest) -> rquickjs::Result<(Value<'js>,)> { let object = rquickjs::Object::new(ctx.clone())?; object.set("request", req.into_js(ctx)?)?; @@ -137,3 +174,28 @@ fn call(name: String, event: Event) -> anyhow::Result> { }) }) } + +fn execute_inner(name: String, value: Option) -> anyhow::Result { + let value = value + .map(|v| serde_json::to_string(&v)) + .ok_or(anyhow::anyhow!("No graphql value found"))??; + + LOCAL_RUNTIME.with_borrow_mut(|cell| { + let runtime = cell + .get_mut() + .ok_or(anyhow::anyhow!("JS runtime not initialized"))?; + runtime.0.with(|ctx| { + let fn_as_value = ctx + .globals() + .get::<_, rquickjs::Function>(&name) + .map_err(|_| anyhow::anyhow!("globalThis not initialized"))?; + + let function = fn_as_value + .as_function() + .ok_or(anyhow::anyhow!("`hello` is not a function"))?; + let val: String = function.call((value, )) + .map_err(|_| anyhow::anyhow!("unable to parse value from js function: {} maybe because it's not returning a string?", name,))?; + Ok::<_, anyhow::Error>(serde_json::from_str(&val)?) + }) + }) +} diff --git a/src/cli/javascript/shim/console.js b/src/javascript/shim/console.js similarity index 100% rename from src/cli/javascript/shim/console.js rename to src/javascript/shim/console.js diff --git a/src/lambda/expression.rs b/src/lambda/expression.rs index 1b050d247b..e1bb59d7ae 100644 --- a/src/lambda/expression.rs +++ b/src/lambda/expression.rs @@ -8,11 +8,12 @@ use async_graphql_value::ConstValue; use thiserror::Error; use super::{Eval, EvaluationContext, ResolverContextLike, IO}; -use crate::auth; use crate::blueprint::DynamicValue; +use crate::javascript::Runtime; use crate::json::JsonLike; use crate::lambda::cache::Cache; use crate::serde_value_ext::ValueExt; +use crate::{auth, WorkerIO}; #[derive(Clone, Debug)] pub enum Expression { @@ -22,6 +23,7 @@ pub enum Expression { Cache(Cache), Path(Box, Vec), Protect(Box), + Js(Arc), } impl Display for Expression { @@ -33,6 +35,7 @@ impl Display for Expression { Expression::Cache(_) => write!(f, "Cache"), Expression::Path(_, _) => write!(f, "Input"), Expression::Protect(expr) => write!(f, "Protected({expr})"), + Expression::Js(_) => write!(f, "Js"), } } } @@ -190,6 +193,10 @@ impl Eval for Expression { } Expression::IO(operation) => operation.eval(ctx).await, Expression::Cache(cached) => cached.eval(ctx).await, + Expression::Js(js) => { + let val = js.call(ctx.value().cloned()).await?; + Ok(val.unwrap_or(async_graphql::Value::Null)) + } } }) } diff --git a/src/lambda/modify.rs b/src/lambda/modify.rs index afd59a3443..2ce6b96329 100644 --- a/src/lambda/modify.rs +++ b/src/lambda/modify.rs @@ -37,6 +37,7 @@ impl Expression { }) } }, + Expression::Js(_) => expr, Expression::Dynamic(_) => expr, Expression::IO(_) => expr, Expression::Cache(Cache { expr, max_age }) => { diff --git a/src/lib.rs b/src/lib.rs index 6ab74992b2..a54d731180 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ pub mod grpc; pub mod has_headers; pub mod helpers; pub mod http; +#[cfg(feature = "js")] +pub mod javascript; pub mod json; pub mod lambda; pub mod merge_right; @@ -85,7 +87,7 @@ pub type EntityCache = dyn Cache; #[async_trait::async_trait] pub trait WorkerIO: Send + Sync + 'static { /// Calls a global JS function - async fn call(&self, name: String, input: In) -> anyhow::Result>; + async fn call(&self, input: In) -> anyhow::Result>; } pub fn is_default(val: &T) -> bool { diff --git a/src/runtime.rs b/src/runtime.rs index fae12cd71e..1a751fa8bf 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -50,10 +50,9 @@ pub mod test { use crate::blueprint::Upstream; use crate::cache::InMemoryCache; - use crate::cli::javascript; use crate::http::Response; use crate::runtime::TargetRuntime; - use crate::{blueprint, EnvIO, FileIO, HttpIO}; + use crate::{blueprint, javascript, EnvIO, FileIO, HttpIO}; #[derive(Clone)] struct TestHttp { diff --git a/tests/core/parse.rs b/tests/core/parse.rs index 303744f300..2076da9dc0 100644 --- a/tests/core/parse.rs +++ b/tests/core/parse.rs @@ -12,11 +12,10 @@ use markdown::mdast::Node; use markdown::ParseOptions; use tailcall::blueprint::Blueprint; use tailcall::cache::InMemoryCache; -use tailcall::cli::javascript; use tailcall::config::{ConfigModule, Source}; use tailcall::http::AppContext; use tailcall::runtime::TargetRuntime; -use tailcall::EnvIO; +use tailcall::{javascript, EnvIO}; use super::file::File; use super::http::Http; diff --git a/tests/core/runtime.rs b/tests/core/runtime.rs index 0b198a4796..f9e6125b00 100644 --- a/tests/core/runtime.rs +++ b/tests/core/runtime.rs @@ -6,11 +6,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use derive_setters::Setters; -use tailcall::blueprint; use tailcall::cache::InMemoryCache; -use tailcall::cli::javascript; use tailcall::config::Source; use tailcall::runtime::TargetRuntime; +use tailcall::{blueprint, javascript}; use super::env::Env; use super::file::TestFileIO; diff --git a/tests/server_spec.rs b/tests/server_spec.rs index d24fbe3387..2b3ca62614 100644 --- a/tests/server_spec.rs +++ b/tests/server_spec.rs @@ -13,8 +13,8 @@ pub mod test { use reqwest::Client; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use tailcall::cache::InMemoryCache; - use tailcall::cli::javascript; use tailcall::http::Response; + use tailcall::javascript; use tailcall::runtime::TargetRuntime; use tokio::io::{AsyncReadExt, AsyncWriteExt};