Files
windmill/backend/windmill-api/src/websocket_triggers.rs
Diego Imbert 7467833239 EE Refactor (#5844)
* app compiles with every ee substituted

* Replace all oss files content

* Revert "Replace all oss files content"

This reverts commit ea4017d59f.

* delete all ee

* hide all _ee files under private flag

* hide every oss stuff when private flag set

* pub use *

* gitignore and substitute script

* pub mod for ee needed for ee repo

* small mistakes

* remove oidc_oss impl

* ee ref (temp)

* ee ref

* fix --all-features selecting private in OSS CI

* ee repo ref

* allow unused
2025-06-02 22:12:33 +02:00

1462 lines
49 KiB
Rust

use anyhow::Context;
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
Extension, Json, Router,
};
use futures::{stream::SplitSink, SinkExt, StreamExt};
use http::StatusCode;
use itertools::Itertools;
use rand::seq::SliceRandom;
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer, Serialize,
};
use serde_json::{value::RawValue, Value};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::prelude::FromRow;
use sqlx::types::Json as SqlxJson;
use std::{collections::HashMap, fmt};
use tokio::net::TcpStream;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, JsonResult},
triggers::TriggerKind,
utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath},
worker::{to_raw_value, CLOUD_HOSTED},
INSTANCE_NAME,
};
use windmill_git_sync::handle_deployment_metadata;
use windmill_queue::PushArgsOwned;
use crate::{
capture::{insert_capture_payload, WebsocketTriggerConfig},
db::{ApiAuthed, DB},
jobs::{
run_flow_by_path_inner, run_script_by_path_inner, run_wait_result_internal, RunJobQuery,
},
trigger_helpers::TriggerJobArgs,
users::fetch_api_authed,
};
use std::borrow::Cow;
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_websocket_trigger))
.route("/list", get(list_websocket_triggers))
.route("/get/*path", get(get_websocket_trigger))
.route("/update/*path", post(update_websocket_trigger))
.route("/delete/*path", delete(delete_websocket_trigger))
.route("/exists/*path", get(exists_websocket_trigger))
.route("/setenabled/*path", post(set_enabled))
.route("/test", post(test_websocket_connection))
}
#[derive(Deserialize)]
struct NewWebsocketTrigger {
path: String,
url: String,
script_path: String,
is_flow: bool,
enabled: Option<bool>,
filters: Vec<Box<RawValue>>,
initial_messages: Option<Vec<Box<RawValue>>>,
url_runnable_args: Option<Box<RawValue>>,
can_return_message: bool,
}
#[derive(Deserialize)]
pub struct JsonFilter {
key: String,
value: serde_json::Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Filter {
JsonFilter(JsonFilter),
}
#[derive(Deserialize)]
enum InitialMessage {
#[serde(rename = "raw_message")]
RawMessage(String),
#[serde(rename = "runnable_result")]
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
}
#[derive(FromRow, Serialize, Clone)]
pub struct WebsocketTrigger {
pub workspace_id: String,
pub path: String,
pub url: String,
pub script_path: String,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_server_ping: Option<chrono::DateTime<chrono::Utc>>,
pub extra_perms: serde_json::Value,
pub error: Option<String>,
pub enabled: bool,
pub filters: Vec<SqlxJson<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<SqlxJson<Box<RawValue>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_runnable_args: Option<SqlxJson<Box<RawValue>>>,
pub can_return_message: bool,
}
#[derive(Deserialize)]
struct EditWebsocketTrigger {
path: String,
url: String,
script_path: String,
is_flow: bool,
filters: Vec<Box<RawValue>>,
initial_messages: Option<Vec<Box<RawValue>>>,
url_runnable_args: Option<Box<RawValue>>,
can_return_message: bool,
}
#[derive(Deserialize)]
pub struct ListWebsocketTriggerQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
pub path_start: Option<String>,
}
async fn list_websocket_triggers(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(lst): Query<ListWebsocketTriggerQuery>,
) -> error::JsonResult<Vec<WebsocketTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("websocket_trigger")
.field("*")
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
if let Some(is_flow) = lst.is_flow {
sqlb.and_where_eq("is_flow", "?".bind(&is_flow));
}
if let Some(path_start) = &lst.path_start {
sqlb.and_where_like_left("path", path_start);
}
let sql = sqlb
.sql()
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, WebsocketTrigger>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn get_websocket_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> error::JsonResult<WebsocketTrigger> {
let mut tx = user_db.begin(&authed).await?;
let path = path.to_path();
let trigger = sqlx::query_as::<_, WebsocketTrigger>(
r#"SELECT *
FROM websocket_trigger
WHERE workspace_id = $1 AND path = $2"#,
)
.bind(w_id)
.bind(path)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let trigger = not_found_if_none(trigger, "Trigger", path)?;
Ok(Json(trigger))
}
async fn create_websocket_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(ct): Json<NewWebsocketTrigger>,
) -> error::Result<(StatusCode, String)> {
if *CLOUD_HOSTED {
return Err(error::Error::BadRequest(
"WebSocket triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host".to_string(),
));
}
let mut tx = user_db.begin(&authed).await?;
let filters = ct.filters.into_iter().map(SqlxJson).collect_vec();
let initial_messages = ct
.initial_messages
.unwrap_or_default()
.into_iter()
.map(SqlxJson)
.collect_vec();
sqlx::query_as::<_, WebsocketTrigger>(
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, enabled, filters, initial_messages, url_runnable_args, edited_by, can_return_message, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now()) RETURNING *",
)
.bind(&w_id)
.bind(&ct.path)
.bind(ct.url)
.bind(ct.script_path)
.bind(ct.is_flow)
.bind(ct.enabled.unwrap_or(true))
.bind(filters.as_slice())
.bind(initial_messages.as_slice())
.bind(ct.url_runnable_args.map(SqlxJson))
.bind(&authed.username)
.bind(ct.can_return_message)
.bind(&authed.email)
.fetch_one(&mut *tx).await?;
audit_log(
&mut *tx,
&authed,
"websocket_triggers.create",
ActionKind::Create,
&w_id,
Some(ct.path.as_str()),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::WebsocketTrigger { path: ct.path.clone() },
Some(format!("WebSocket trigger '{}' created", ct.path)),
true,
)
.await?;
Ok((StatusCode::CREATED, format!("{}", ct.path)))
}
async fn update_websocket_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ct): Json<EditWebsocketTrigger>,
) -> error::Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
let filters = ct.filters.into_iter().map(SqlxJson).collect_vec();
let initial_messages = ct
.initial_messages
.unwrap_or_default()
.into_iter()
.map(SqlxJson)
.collect_vec();
// important to update server_id to NULL to stop current websocket listener
sqlx::query!(
"UPDATE websocket_trigger SET url = $1, script_path = $2, path = $3, is_flow = $4, filters = $5, initial_messages = $6, url_runnable_args = $7, edited_by = $8, email = $9, can_return_message = $10, edited_at = now(), server_id = NULL, error = NULL
WHERE workspace_id = $11 AND path = $12",
ct.url,
ct.script_path,
ct.path,
ct.is_flow,
filters.as_slice() as &[SqlxJson<Box<RawValue>>],
initial_messages.as_slice() as &[SqlxJson<Box<RawValue>>],
ct.url_runnable_args.map(SqlxJson) as Option<SqlxJson<Box<RawValue>>>,
&authed.username,
&authed.email,
ct.can_return_message,
w_id,
path,
)
.execute(&mut *tx).await?;
audit_log(
&mut *tx,
&authed,
"websocket_triggers.update",
ActionKind::Update,
&w_id,
Some(&ct.path),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::WebsocketTrigger { path: ct.path.clone() },
Some(format!("WebSocket trigger '{}' updated", ct.path)),
true,
)
.await?;
Ok(ct.path.to_string())
}
#[derive(Deserialize)]
pub struct SetEnabled {
pub enabled: bool,
}
pub async fn set_enabled(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(payload): Json<SetEnabled>,
) -> error::Result<String> {
let mut tx = user_db.begin(&authed).await?;
let path = path.to_path();
// important to set server_id, last_server_ping and error to NULL to stop current websocket listener
let one_o = sqlx::query_scalar!(
"UPDATE websocket_trigger SET enabled = $1, email = $2, edited_by = $3, edited_at = now(), server_id = NULL, error = NULL
WHERE path = $4 AND workspace_id = $5 RETURNING 1",
payload.enabled,
&authed.email,
&authed.username,
path,
w_id,
).fetch_optional(&mut *tx).await?;
not_found_if_none(one_o.flatten(), "WebSocket trigger", path)?;
audit_log(
&mut *tx,
&authed,
"websocket_triggers.setenabled",
ActionKind::Update,
&w_id,
Some(path),
Some([("enabled", payload.enabled.to_string().as_ref())].into()),
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::WebsocketTrigger { path: path.to_string() },
Some(format!("WebSocket trigger '{}' updated", path)),
true,
)
.await?;
Ok(format!(
"succesfully updated WebSocket trigger at path {} to status {}",
path, payload.enabled
))
}
async fn delete_websocket_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> error::Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"DELETE FROM websocket_trigger WHERE workspace_id = $1 AND path = $2",
w_id,
path,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"websocket_triggers.delete",
ActionKind::Delete,
&w_id,
Some(path),
None,
)
.await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::WebsocketTrigger { path: path.to_string() },
Some(format!("WebSocket trigger '{}' deleted", path)),
true,
)
.await?;
Ok(format!("WebSocket trigger {path} deleted"))
}
async fn exists_websocket_trigger(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<bool> {
let path = path.to_path();
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM websocket_trigger WHERE path = $1 AND workspace_id = $2)",
path,
w_id,
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Debug, Deserialize)]
struct TestWebsocket {
url: String,
url_runnable_args: Option<Box<RawValue>>,
}
async fn test_websocket_connection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
Json(test_websocket): Json<TestWebsocket>,
) -> error::Result<()> {
let url = test_websocket.url;
let connect_f = async {
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
let path = url.splitn(2, ':').nth(1).unwrap();
Cow::Owned(
get_url_from_runnable(
path,
url.starts_with("$flow:"),
&db,
authed,
test_websocket.url_runnable_args.as_ref(),
&workspace_id,
)
.await?,
)
} else {
return Err(error::Error::BadConfig(format!(
"Invalid WebSocket runnable path: {}",
url
)));
}
} else {
Cow::Borrowed(&url)
};
connect_async(connect_url.as_ref()).await.map_err(|err| {
error::Error::BadConfig(format!(
"Error connecting to WebSocket: {}",
err.to_string()
))
})?;
Ok(())
};
tokio::time::timeout(tokio::time::Duration::from_secs(30), connect_f)
.await
.map_err(|_| {
error::Error::BadConfig(format!("Timeout connecting to WebSocket after 30 seconds"))
})??;
Ok(())
}
async fn listen_to_unlistened_websockets(
db: &DB,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) {
match sqlx::query_as::<_, WebsocketTrigger>(
r#"SELECT *
FROM websocket_trigger
WHERE enabled IS TRUE AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')"#
)
.fetch_all(db)
.await
{
Ok(mut triggers) => {
triggers.shuffle(&mut rand::rng());
for trigger in triggers {
trigger.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe()).await;
}
}
Err(err) => {
tracing::error!("Error fetching WebSocket triggers: {:?}", err);
}
};
match sqlx::query_as!(
CaptureConfigForWebsocket,
r#"SELECT path, is_flow, workspace_id, trigger_config as "trigger_config!: _", owner, email FROM capture_config WHERE trigger_kind = 'websocket' AND last_client_ping > NOW() - INTERVAL '10 seconds' AND trigger_config IS NOT NULL AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')"#
)
.fetch_all(db)
.await
{
Ok(mut captures) => {
captures.shuffle(&mut rand::rng());
for capture in captures {
capture.maybe_listen_to_websocket(db.clone(), killpill_rx.resubscribe()).await;
}
}
Err(err) => {
tracing::error!("Error fetching capture WebSocket triggers: {:?}", err);
}
}
}
pub fn start_websockets(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) -> () {
tokio::spawn(async move {
listen_to_unlistened_websockets(&db, &killpill_rx).await;
loop {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(15)) => {
listen_to_unlistened_websockets(&db, &killpill_rx).await;
}
}
}
});
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
}
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a JSON object with a specific key at the top level")
}
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
while let Some(key) = map.next_key::<String>()? {
if key == self.key {
// Deserialize the value for the key and check if it's a superset
let json_value: Value = map.next_value()?;
return Ok(is_superset(&json_value, self.value_to_check));
} else {
// Skip the value if it's not the one we're interested in
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
// If the key was not found, return false
Ok(false)
}
}
// Function to check if json_value is a superset of value_to_check
fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
match (json_value, value_to_check) {
(Value::Object(json_map), Value::Object(check_map)) => {
// Check that all keys and values in check_map exist and match in json_map
check_map.iter().all(|(k, v)| {
json_map
.get(k)
.map_or(false, |json_val| is_superset(json_val, v))
})
}
(Value::Array(json_array), Value::Array(check_array)) => {
// Check that all elements in check_array exist in json_array
check_array.iter().all(|check_item| {
json_array
.iter()
.any(|json_item| is_superset(json_item, check_item))
})
}
_ => json_value == value_to_check,
}
}
// A function to deserialize and check if the value at the given key is a superset of a passed value
fn is_value_superset<'a, 'de, D>(
deserializer: D,
key: &'a str,
value_to_check: &'a Value,
) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
}
fn raw_value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> error::Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
serde_json::from_str::<Option<HashMap<String, Box<RawValue>>>>(args.get())
.map_err(|e| error::Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(HashMap::new)
} else {
HashMap::new()
};
Ok(args)
}
async fn wait_runnable_result(
path: String,
is_flow: bool,
args: PushArgsOwned,
authed: ApiAuthed,
db: &DB,
workspace_id: &str,
) -> error::Result<String> {
let user_db = UserDB::new(db.clone());
let username = authed.display_username().to_owned();
let (job_id, early_return) = if is_flow {
let (_, job_id) = run_flow_by_path_inner(
authed,
db.clone(),
user_db,
workspace_id.to_string(),
StripPath(path.clone()),
RunJobQuery::default(),
args,
)
.await?;
let early_return = sqlx::query_scalar!(
r#"SELECT flow_version.value->>'early_return' as early_return
FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 and flow.workspace_id = $2"#,
path,
workspace_id,
)
.fetch_optional(db)
.await?
.flatten();
(job_id, early_return)
} else {
let (_, job_id) = run_script_by_path_inner(
authed,
db.clone(),
user_db,
workspace_id.to_string(),
StripPath(path.clone()),
RunJobQuery::default(),
args,
)
.await?;
(job_id, None)
};
let (result, success) = run_wait_result_internal(
db,
Uuid::parse_str(&job_id).unwrap(),
workspace_id.to_string(),
early_return,
&username,
)
.await
.with_context(|| {
format!(
"Error fetching job result for {} {}",
if is_flow { "flow" } else { "script" },
path
)
})?;
if !success {
Err(anyhow::anyhow!(
"{} {path} failed: {:?}",
if is_flow { "Flow" } else { "Script" },
result
)
.into())
} else {
Ok(result.get().to_owned())
}
}
async fn loop_ping(db: &DB, ws: &WebsocketEnum, error: Option<&str>) -> () {
loop {
if let None = ws.update_ping(db, error).await {
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
async fn get_url_from_runnable(
path: &str,
is_flow: bool,
db: &DB,
authed: ApiAuthed,
args: Option<&Box<RawValue>>,
workspace_id: &str,
) -> error::Result<String> {
tracing::info!(
"Running {} {} to get WebSocket URL",
if is_flow { "flow" } else { "script" },
path
);
let args = raw_value_to_args_hashmap(args)?;
let result = wait_runnable_result(
path.to_string(),
is_flow,
PushArgsOwned { args, extra: None },
authed,
db,
workspace_id,
)
.await?;
serde_json::from_str::<String>(result.as_str()).map_err(|_| {
error::Error::BadConfig(format!(
"{} {} did not return a string",
if is_flow { "Flow" } else { "Script" },
path,
))
})
}
impl WebsocketTrigger {
async fn maybe_listen_to_websocket(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
"UPDATE websocket_trigger SET server_id = $1, last_server_ping = now(), error = 'Connecting...' WHERE enabled IS TRUE AND workspace_id = $2 AND path = $3 AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
*INSTANCE_NAME,
self.workspace_id,
self.path,
).fetch_optional(&db).await {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn(listen_to_websocket(WebsocketEnum::Trigger(self), db, killpill_rx));
} else {
tracing::info!("WebSocket {} already being listened to", self.url);
}
},
Err(err) => {
tracing::error!("Error acquiring lock for WebSocket {}: {:?}", self.path, err);
}
};
}
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match sqlx::query_scalar!(
"UPDATE websocket_trigger SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND server_id = $4 AND enabled IS TRUE RETURNING 1",
error,
self.workspace_id,
self.path,
*INSTANCE_NAME
).fetch_optional(db).await {
Ok(updated) => {
if updated.flatten().is_none() {
// allow faster restart of websocket trigger
sqlx::query!(
"UPDATE websocket_trigger SET last_server_ping = NULL WHERE workspace_id = $1 AND path = $2 AND server_id IS NULL",
self.workspace_id,
self.path,
).execute(db).await.ok();
tracing::info!("WebSocket {} changed, disabled, or deleted, stopping...", self.url);
return None;
}
},
Err(err) => {
tracing::warn!("Error updating ping of WebSocket {}: {:?}", self.url, err);
}
};
Some(())
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
match sqlx::query!(
"UPDATE websocket_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
error,
self.workspace_id,
self.path,
)
.execute(db).await {
Ok(_) => {
report_critical_error(format!("Disabling WebSocket {} because of error: {}", self.url, error), db.clone(), Some(&self.workspace_id), None).await;
},
Err(disable_err) => {
report_critical_error(
format!("Could not disable WebSocket {} with err {}, disabling because of error {}", self.path, disable_err, error),
db.clone(),
Some(&self.workspace_id),
None,
).await;
}
}
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
get_url_from_runnable(
&path,
is_flow,
db,
self.fetch_authed(db).await?,
self.url_runnable_args.as_ref().map(|r| &r.0),
&self.workspace_id,
)
.await
}
async fn send_initial_messages(
&self,
writer: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
db: &DB,
) -> error::Result<()> {
let initial_messages: Vec<InitialMessage> = self
.initial_messages
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec();
for start_message in initial_messages {
match start_message {
InitialMessage::RawMessage(msg) => {
let msg = if msg.starts_with("\"") && msg.ends_with("\"") {
msg[1..msg.len() - 1].to_string()
} else {
msg
};
tracing::info!(
"Sending raw message initial message to WebSocket {}: {}",
self.url,
msg
);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(msg))
.await
.map_err(to_anyhow)
.with_context(|| "failed to send raw message")?;
}
InitialMessage::RunnableResult { path, is_flow, args } => {
tracing::info!(
"Running {} {} for initial message to WebSocket {}",
if is_flow { "flow" } else { "script" },
path,
self.url,
);
let args = raw_value_to_args_hashmap(Some(&args))?;
let result = wait_runnable_result(
path.clone(),
is_flow,
PushArgsOwned { args, extra: None },
self.fetch_authed(db).await?,
db,
&self.workspace_id,
)
.await?;
tracing::info!(
"Sending {} {} result to WebSocket {}",
if is_flow { "flow" } else { "script" },
path,
self.url
);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(result))
.await
.map_err(to_anyhow)
.with_context(|| {
format!(
"Failed to send {} {} result",
if is_flow { "flow" } else { "script" },
path
)
})?;
}
}
}
Ok(())
}
async fn handle(
&self,
db: &DB,
msg: &str,
trigger_info: HashMap<String, Box<RawValue>>,
return_message_channels: Option<ReturnMessageChannels>,
) -> () {
if let Err(err) = run_job(db, self, &msg, trigger_info, return_message_channels).await {
report_critical_error(
format!(
"Failed to trigger job from WebSocket {}: {:?}",
self.url, err
),
db.clone(),
Some(&self.workspace_id),
None,
)
.await;
};
}
async fn fetch_authed(&self, db: &DB) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.edited_by.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("ws-{}", self.path)),
)
.await
}
}
impl TriggerJobArgs<&str> for WebsocketTrigger {
fn v1_payload_fn(payload: &str) -> HashMap<String, Box<RawValue>> {
HashMap::from([("msg".to_string(), to_raw_value(&payload))])
}
fn trigger_kind() -> TriggerKind {
TriggerKind::Websocket
}
}
#[derive(Deserialize)]
struct CaptureConfigForWebsocket {
trigger_config: SqlxJson<WebsocketTriggerConfig>,
path: String,
is_flow: bool,
workspace_id: String,
owner: String,
email: String,
}
impl CaptureConfigForWebsocket {
async fn maybe_listen_to_websocket(
self,
db: DB,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match sqlx::query_scalar!(
"UPDATE capture_config SET server_id = $1, last_server_ping = now(), error = 'Connecting...' WHERE last_client_ping > NOW() - INTERVAL '10 seconds' AND workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') RETURNING true",
*INSTANCE_NAME,
self.workspace_id,
self.path,
self.is_flow,
).fetch_optional(&db).await {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn(listen_to_websocket(WebsocketEnum::Capture(self), db, killpill_rx));
} else {
tracing::info!("WebSocket {} already being listened to", self.trigger_config.url);
}
},
Err(err) => {
tracing::error!("Error acquiring lock for capture WebSocket {}: {:?}", self.path, err);
}
};
}
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match sqlx::query_scalar!(
"UPDATE capture_config SET last_server_ping = now(), error = $1 WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket' AND server_id = $5 AND last_client_ping > NOW() - INTERVAL '10 seconds' RETURNING 1",
error,
self.workspace_id,
self.path,
self.is_flow,
*INSTANCE_NAME
).fetch_optional(db).await {
Ok(updated) => {
if updated.flatten().is_none() {
// allow faster restart of websocket capture
sqlx::query!(
"UPDATE capture_config SET last_server_ping = NULL WHERE workspace_id = $1 AND path = $2 AND is_flow = $3 AND trigger_kind = 'websocket' AND server_id IS NULL",
self.workspace_id,
self.path,
self.is_flow,
).execute(db).await.ok();
tracing::info!("WebSocket capture {} changed, disabled, or deleted, stopping...", self.trigger_config.url);
return None;
}
},
Err(err) => {
tracing::warn!("Error updating ping of capture WebSocket {}: {:?}", self.trigger_config.url, err);
}
};
Some(())
}
async fn handle(&self, db: &DB, msg: &str, trigger_info: HashMap<String, Box<RawValue>>) -> () {
let (main_args, preprocessor_args) =
WebsocketTrigger::build_capture_payloads(&msg, trigger_info);
if let Err(err) = insert_capture_payload(
db,
&self.workspace_id,
&self.path,
self.is_flow,
&TriggerKind::Websocket,
main_args,
preprocessor_args,
&self.owner,
)
.await
{
tracing::error!("Error inserting capture payload: {:?}", err);
}
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
let url_runnable_args = self
.trigger_config
.url_runnable_args
.as_ref()
.map(to_raw_value);
get_url_from_runnable(
&path,
is_flow,
db,
self.fetch_authed(db).await?,
url_runnable_args.as_ref(),
&self.workspace_id,
)
.await
}
async fn fetch_authed(&self, db: &DB) -> error::Result<ApiAuthed> {
fetch_api_authed(
self.owner.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("ws-{}", self.get_trigger_path())),
)
.await
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
if let Err(err) = sqlx::query!(
"UPDATE capture_config SET error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3 AND is_flow = $4 AND trigger_kind = 'websocket'",
error,
self.workspace_id,
self.path,
self.is_flow,
)
.execute(db).await {
tracing::error!("Could not disable WebSocket capture {} ({}) with err {}, disabling because of error {}", self.path, self.workspace_id, err, error);
}
}
fn get_trigger_path(&self) -> String {
format!(
"{}-{}",
if self.is_flow { "flow" } else { "script" },
self.path
)
}
}
enum WebsocketEnum {
Trigger(WebsocketTrigger),
Capture(CaptureConfigForWebsocket),
}
impl WebsocketEnum {
async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> {
match self {
WebsocketEnum::Trigger(ws) => ws.update_ping(db, error).await,
WebsocketEnum::Capture(capture) => capture.update_ping(db, error).await,
}
}
async fn get_url_from_runnable(
&self,
path: &str,
is_flow: bool,
db: &DB,
) -> error::Result<String> {
match self {
WebsocketEnum::Trigger(ws) => ws.get_url_from_runnable(path, is_flow, db).await,
WebsocketEnum::Capture(capture) => {
capture.get_url_from_runnable(path, is_flow, db).await
}
}
}
async fn disable_with_error(&self, db: &DB, error: String) -> () {
match self {
WebsocketEnum::Trigger(ws) => ws.disable_with_error(db, error).await,
WebsocketEnum::Capture(capture) => capture.disable_with_error(db, error).await,
}
}
}
struct ReturnMessageChannels {
send_message_tx: tokio::sync::mpsc::Sender<String>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
}
impl Clone for ReturnMessageChannels {
fn clone(&self) -> Self {
Self {
send_message_tx: self.send_message_tx.clone(),
killpill_rx: self.killpill_rx.resubscribe(),
}
}
}
async fn listen_to_websocket(
ws: WebsocketEnum,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
let url = match &ws {
WebsocketEnum::Trigger(ws_trigger) => ws_trigger.url.clone(),
WebsocketEnum::Capture(capture) => capture.trigger_config.url.clone(),
};
let filters: Vec<Filter> = match &ws {
WebsocketEnum::Trigger(ws_trigger) => ws_trigger
.filters
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec(),
WebsocketEnum::Capture(_) => vec![],
};
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
let path = url.splitn(2, ':').nth(1).unwrap();
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
},
_ = loop_ping(&db, &ws, Some(
"Waiting on runnable to return WebSocket URL..."
)) => {
return;
},
url_result = ws.get_url_from_runnable(path, url.starts_with("$flow:"), &db) => match url_result {
Ok(url) => Cow::Owned(url),
Err(err) => {
ws.disable_with_error(&db, format!(
"Error getting WebSocket URL from runnable after 5 tries: {:?}",
err
),
)
.await;
return;
}
},
}
} else {
ws.disable_with_error(&db, format!("Invalid WebSocket runnable path: {}", url))
.await;
return;
}
} else {
Cow::Borrowed(&url)
};
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
},
_ = loop_ping(&db, &ws, Some("Connecting...")) => {
return;
},
connection = connect_async(connect_url.as_ref()) => {
match connection {
Ok((ws_stream, _)) => {
tracing::info!("Connected to WebSocket {}", url);
let (mut writer, mut reader) = ws_stream.split();
// send initial messages
match &ws {
WebsocketEnum::Trigger(ws_trigger) => {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
},
_ = loop_ping(&db, &ws, Some("Sending initial messages...")) => {
return;
},
result = ws_trigger.send_initial_messages(&mut writer, &db) => {
if let Err(err) = result {
ws_trigger.disable_with_error(&db, format!("Error sending initial messages: {:?}", err)).await;
return
} else {
tracing::debug!("Initial messages sent successfully to WebSocket {}", url);
}
}
}
},
_ => {
}
}
let (return_message_channels, message_sender_handle) = match &ws {
WebsocketEnum::Trigger(ws_trigger) if ws_trigger.can_return_message => {
let (send_message_tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
let w_id = ws_trigger.workspace_id.clone();
let url = ws_trigger.url.clone();
let db = db.clone();
let handle = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if let Err(err) = writer.send(tokio_tungstenite::tungstenite::Message::Text(message)).await {
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db.clone(), Some(&w_id), None).await;
}
}
});
let killpill_rx = killpill_rx.resubscribe();
let return_message_channels = ReturnMessageChannels {
send_message_tx,
killpill_rx
};
(Some(return_message_channels), Some(handle))
},
_ => (None, None)
};
tokio::select! {
biased;
_ = killpill_rx.recv() => {},
_ = loop_ping(&db, &ws, None) => {},
_ = async {
loop {
if let Some(msg) = reader.next().await {
match msg {
Ok(msg) => {
match msg {
tokio_tungstenite::tungstenite::Message::Text(text) => {
tracing::debug!("Received text message from WebSocket {}: {}", url, text);
let mut should_handle = true;
for filter in &filters {
match filter {
Filter::JsonFilter(JsonFilter { key, value }) => {
let mut deserializer = serde_json::Deserializer::from_str(text.as_str());
should_handle = match is_value_superset(&mut deserializer, key, &value) {
Ok(filter_match) => {
filter_match
},
Err(err) => {
tracing::warn!("Error deserializing filter for WebSocket {}: {:?}", url, err);
false
}
};
}
}
if !should_handle {
break;
}
}
if should_handle {
let trigger_info = HashMap::from([
("url".to_string(), to_raw_value(&url)),
]);
match &ws {
WebsocketEnum::Trigger(ws_trigger) => {
ws_trigger.handle(&db, &text, trigger_info, return_message_channels.clone()).await;
},
WebsocketEnum::Capture(capture) => {
capture.handle(&db, &text, trigger_info).await;
},
}
}
},
a @ _ => {
tracing::debug!("Received non text-message from WebSocket {}: {:?}", url, a);
}
}
},
Err(err) => {
tracing::error!("Error reading from WebSocket {}: {:?}", url, err);
}
}
} else {
tracing::error!("WebSocket {} closed", url);
ws.update_ping(&db, Some("WebSocket closed")).await;
break;
}
}
} => {}
}
// make sure to stop return message handler
if let Some(message_sender_handle) = message_sender_handle {
message_sender_handle.abort();
}
}
Err(err) => {
tracing::error!("Error connecting to WebSocket {}: {:?}", url, err);
ws.update_ping(&db, Some(err.to_string().as_str())).await;
}
}
}
}
}
async fn run_job(
db: &DB,
trigger: &WebsocketTrigger,
msg: &str,
trigger_info: HashMap<String, Box<RawValue>>,
return_message_channels: Option<ReturnMessageChannels>,
) -> anyhow::Result<()> {
let args = WebsocketTrigger::build_job_args(
&trigger.script_path,
trigger.is_flow,
&trigger.workspace_id,
db,
msg,
trigger_info,
)
.await?;
let authed = fetch_api_authed(
trigger.edited_by.clone(),
trigger.email.clone(),
&trigger.workspace_id,
db,
Some(format!("ws-{}", trigger.path)),
)
.await?;
if let Some(ReturnMessageChannels { send_message_tx, mut killpill_rx }) =
return_message_channels
{
let db_ = db.clone();
let url = trigger.url.clone();
let script_path = trigger.script_path.clone();
let is_flow = trigger.is_flow;
let w_id = trigger.workspace_id.clone();
let handle_response_f = async move {
tokio::select! {
_ = killpill_rx.recv() => {
return;
},
result = wait_runnable_result(
script_path,
is_flow,
args,
authed,
&db_,
&w_id,
) => {
if let Ok(result) = result {
// only send the result if it's not null
if result != "null" {
tracing::info!("Sending job result to WebSocket {}", url);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
if let Err(err) = send_message_tx.send(result).await {
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db_.clone(), Some(&w_id), None).await;
}
}
}
}
};
};
tokio::spawn(handle_response_f);
} else {
let user_db = UserDB::new(db.clone());
let run_query = RunJobQuery::default();
let runnable_path = StripPath(trigger.script_path.to_owned());
if trigger.is_flow {
run_flow_by_path_inner(
authed,
db.clone(),
user_db,
trigger.workspace_id.clone(),
runnable_path,
run_query,
args,
)
.await?;
} else {
run_script_by_path_inner(
authed,
db.clone(),
user_db,
trigger.workspace_id.clone(),
runnable_path,
run_query,
args,
)
.await?;
}
}
Ok(())
}