From c748f4994172627d8f0c4c5c1b5bfebc802deaa9 Mon Sep 17 00:00:00 2001 From: frelodev Date: Sun, 10 Mar 2024 12:42:56 +0100 Subject: [PATCH] feat:added js glue --- .gitignore | 14 +++-- Cargo.toml | 9 +++- build.rs | 7 +++ package.json | 5 ++ src/ffi/js/buffer.rs | 110 ++++++++++++++++++++++++++++++++++++++++ src/ffi/js/client.rs | 45 ++++++++++++++++ src/ffi/js/cursor.rs | 90 ++++++++++++++++++++++++++++++++ src/ffi/js/mod.rs | 68 +++++++++++++++++++++++++ src/ffi/js/op_cache.rs | 104 +++++++++++++++++++++++++++++++++++++ src/ffi/js/workspace.rs | 56 ++++++++++++++++++++ src/ffi/mod.rs | 3 ++ 11 files changed, 502 insertions(+), 9 deletions(-) create mode 100644 package.json create mode 100644 src/ffi/js/buffer.rs create mode 100644 src/ffi/js/client.rs create mode 100644 src/ffi/js/cursor.rs create mode 100644 src/ffi/js/mod.rs create mode 100644 src/ffi/js/op_cache.rs create mode 100644 src/ffi/js/workspace.rs diff --git a/.gitignore b/.gitignore index 2339481..56c86d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ /target - Cargo.lock - -# vscode extension build files -/client/vscode/node_modules/ -/client/vscode/*.vsix -/client/vscode/codemp.node - .cargo +.vscode/ -.vscode/ \ No newline at end of file +# js +node_modules/ +package-lock.json +index.d.ts +index.node \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e0f0c9b..aa47726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,14 +39,21 @@ log = { version = "0.4.21", optional = true } mlua = { version = "0.9.6", features = ["module", "luajit", "send"], optional = true } thiserror = { version = "1.0.57", optional = true } derive_more = { version = "0.99.17", optional = true } - +# glue (js) +rmpv = { version = "1", optional = true } +napi = { version = "2", features = ["full"], optional = true } +napi-derive = { version="2", optional = true} +futures = { version = "0.3.28", optional = true } [build-dependencies] # glue (java) flapigen = { version = "0.6.0", optional = true } rifgen = { git = "https://github.com/Kofituo/rifgen.git", rev = "d27d9785b2febcf5527f1deb6a846be5d583f7d7", optional = true } +# glue (js) +napi-build = { version = "2", optional = true } [features] default = [] lua = ["mlua", "thiserror", "derive_more", "lazy_static", "tracing-subscriber"] java = ["lazy_static", "jni", "jni-sys", "flapigen", "rifgen", "log"] java-artifact = ["java"] # also builds the jar +js = ["napi-build", "tracing-subscriber", "rmpv", "napi", "napi-derive", "futures"] \ No newline at end of file diff --git a/build.rs b/build.rs index 6204dfc..a908842 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "js")] +extern crate napi_build; + /// The main method of the buildscript, required by some glue modules. fn main() { #[cfg(feature = "java")] { @@ -74,6 +77,10 @@ fn main() { println!("cargo:rerun-if-changed={}", generated_glue_file.display()); } } + + #[cfg(feature = "js")] { + napi_build::setup(); + } } #[cfg(feature = "java")] diff --git a/package.json b/package.json new file mode 100644 index 0000000..5486150 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@napi-rs/cli": "^2.18.0" + } +} diff --git a/src/ffi/js/buffer.rs b/src/ffi/js/buffer.rs new file mode 100644 index 0000000..342b9cb --- /dev/null +++ b/src/ffi/js/buffer.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; +use napi::threadsafe_function::{ErrorStrategy::Fatal, ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi_derive::napi; +use crate::api::Controller; +use crate::ffi::js::JsCodempError; + +/// BUFFER +#[napi(object)] +pub struct JsTextChange { + pub span: JsRange, + pub content: String, +} + +#[napi(object)] +pub struct JsRange{ + pub start: i32, + pub end: i32, +} + +impl From:: for JsTextChange { + fn from(value: crate::api::TextChange) -> Self { + JsTextChange { + // TODO how is x.. represented ? span.end can never be None + span: JsRange { start: value.span.start as i32, end: value.span.end as i32 }, + content: value.content, + } + } +} + + +impl From::> for JsBufferController { + fn from(value: Arc) -> Self { + JsBufferController(value) + } +} + + +#[napi] +pub struct JsBufferController(Arc); + + +/*#[napi] +pub fn delta(string : String, start: i64, txt: String, end: i64 ) -> Option { + Some(JsCodempOperationSeq(string.diff(start as usize, &txt, end as usize)?)) +}*/ + + + + + + +#[napi] +impl JsBufferController { + + + #[napi(ts_args_type = "fun: (event: JsTextChange) => void")] + pub fn callback(&self, fun: napi::JsFunction) -> napi::Result<()>{ + let tsfn : ThreadsafeFunction = + fun.create_threadsafe_function(0, + |ctx : ThreadSafeCallContext| { + Ok(vec![JsTextChange::from(ctx.value)]) + } + )?; + let _controller = self.0.clone(); + tokio::spawn(async move { + //tokio::time::sleep(std::time::Duration::from_secs(1)).await; + loop { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + match _controller.recv().await { + Ok(event) => { + tsfn.call(event, ThreadsafeFunctionCallMode::NonBlocking); //check this shit with tracing also we could use Ok(event) to get the error + }, + Err(crate::Error::Deadlocked) => continue, + Err(e) => break tracing::warn!("error receiving: {}", e), + } + } + }); + Ok(()) + } + + + #[napi] + pub fn content(&self) -> String { + self.0.content() + } + + #[napi] + pub fn get_name(&self) -> String { + self.0.name().to_string() + } + + #[napi] + pub async fn recv(&self) -> napi::Result { + Ok( + self.0.recv().await + .map_err(|e| napi::Error::from(JsCodempError(e)))? + .into() + ) + } + + #[napi] + pub fn send(&self, op: JsTextChange) -> napi::Result<()> { + // TODO might be nice to take ownership of the opseq + let new_text_change = crate::api::TextChange { + span: op.span.start as usize .. op.span.end as usize, + content: op.content, + }; + Ok(self.0.send(new_text_change).map_err(JsCodempError)?) + } +} \ No newline at end of file diff --git a/src/ffi/js/client.rs b/src/ffi/js/client.rs new file mode 100644 index 0000000..5527f74 --- /dev/null +++ b/src/ffi/js/client.rs @@ -0,0 +1,45 @@ +use napi_derive::napi; +use crate::ffi::js::JsCodempError; +use crate::ffi::js::workspace::JsWorkspace; + +#[napi] +/// main codemp client session +pub struct JsCodempClient(tokio::sync::RwLock); + +#[napi] +/// connect to codemp servers and return a client session +pub async fn connect(addr: Option) -> napi::Result{ + let client = crate::Client::new(addr.as_deref().unwrap_or("http://codemp.alemi.dev:50053")) + .await + .map_err(JsCodempError)?; + + Ok(JsCodempClient(tokio::sync::RwLock::new(client))) +} + +#[napi] +impl JsCodempClient { + #[napi] + /// login against AuthService with provided credentials, optionally requesting access to a workspace + pub async fn login(&self, username: String, password: String, workspace_id: Option) -> napi::Result<()> { + self.0.read().await.login(username, password, workspace_id).await.map_err(JsCodempError)?; + Ok(()) + } + + #[napi] + /// join workspace with given id (will start its cursor controller) + pub async fn join_workspace(&self, workspace: String) -> napi::Result { + Ok(JsWorkspace::from(self.0.write().await.join_workspace(&workspace).await.map_err(JsCodempError)?)) + } + + #[napi] + /// get workspace with given id, if it exists + pub async fn get_workspace(&self, workspace: String) -> Option { + self.0.read().await.get_workspace(&workspace).map(|w| JsWorkspace::from(w)) + } + + #[napi] + /// return current sessions's user id + pub async fn user_id(&self) -> String { + self.0.read().await.user_id().to_string() + } +} \ No newline at end of file diff --git a/src/ffi/js/cursor.rs b/src/ffi/js/cursor.rs new file mode 100644 index 0000000..8c994c8 --- /dev/null +++ b/src/ffi/js/cursor.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; +use napi_derive::napi; +use uuid::Uuid; +use napi::threadsafe_function::{ThreadsafeFunction, ThreadSafeCallContext, ThreadsafeFunctionCallMode, ErrorStrategy}; +use crate::api::Controller; +use crate::ffi::js::JsCodempError; + +#[napi] +pub struct JsCursorController(Arc); + +impl From::> for JsCursorController { + fn from(value: Arc) -> Self { + JsCursorController(value) + } +} + +#[napi] +impl JsCursorController { + + #[napi(ts_args_type = "fun: (event: JsCursorEvent) => void")] + pub fn callback(&self, fun: napi::JsFunction) -> napi::Result<()>{ + let tsfn : ThreadsafeFunction = + fun.create_threadsafe_function(0, + |ctx : ThreadSafeCallContext| { + Ok(vec![JsCursorEvent::from(ctx.value)]) + } + )?; + let _controller = self.0.clone(); + tokio::spawn(async move { + loop { + match _controller.recv().await { + Ok(event) => { + tsfn.call(event.clone(), ThreadsafeFunctionCallMode::NonBlocking); //check this shit with tracing also we could use Ok(event) to get the error + }, + Err(crate::Error::Deadlocked) => continue, + Err(e) => break tracing::warn!("error receiving: {}", e), + } + } + }); + Ok(()) + } + + #[napi] + pub fn send(&self, buffer: String, start: (i32, i32), end: (i32, i32)) -> napi::Result<()> { + let pos = codemp_proto::cursor::CursorPosition { + buffer: buffer.into(), + start: codemp_proto::cursor::RowCol::from(start), + end: codemp_proto::cursor::RowCol::from(end), + }; + Ok(self.0.send(pos).map_err(JsCodempError)?) + } +} + + + +#[derive(Debug)] +#[napi(object)] +pub struct JsCursorEvent { + pub user: String, + pub buffer: String, + pub start: JsRowCol, + pub end: JsRowCol, +} + +impl From:: for JsCursorEvent { + fn from(value: codemp_proto::cursor::CursorEvent) -> Self { + let pos = value.position; + let start = pos.start; + let end = pos.end; + JsCursorEvent { + user: Uuid::from(value.user).to_string(), + buffer: pos.buffer.into(), + start: JsRowCol { row: start.row, col: start.col }, + end: JsRowCol { row: end.row, col: end.col }, + } + } +} + +#[derive(Debug)] +#[napi(object)] +pub struct JsRowCol { + pub row: i32, + pub col: i32 +} + +impl From:: for JsRowCol { + fn from(value: codemp_proto::cursor::RowCol) -> Self { + JsRowCol { row: value.row, col: value.col } + } +} \ No newline at end of file diff --git a/src/ffi/js/mod.rs b/src/ffi/js/mod.rs new file mode 100644 index 0000000..cca5e30 --- /dev/null +++ b/src/ffi/js/mod.rs @@ -0,0 +1,68 @@ +#![deny(clippy::all)] + +pub mod client; +pub mod workspace; +pub mod cursor; +pub mod buffer; +pub mod op_cache; + +#[derive(Debug)] +struct JsCodempError(crate::Error); + +impl From:: for napi::Error { + fn from(value: JsCodempError) -> Self { + napi::Error::new(napi::Status::GenericFailure, &format!("CodempError: {:?}", value)) + } +} + +use napi_derive::napi; + +#[napi] +pub struct JsLogger(std::sync::Arc>>); + +#[napi] +impl JsLogger { + #[napi(constructor)] + pub fn new(debug: Option) -> JsLogger { + let (tx, rx) = tokio::sync::mpsc::channel(256); + let level = if debug.unwrap_or(false) { tracing::Level::DEBUG } else {tracing::Level::INFO }; //TODO: study this tracing subscriber and customize it + let format = tracing_subscriber::fmt::format() + .with_level(true) + .with_target(true) + .with_thread_ids(false) + .with_thread_names(false) + .with_ansi(false) + .with_file(false) + .with_line_number(false) + .with_source_location(false) + .compact(); + tracing_subscriber::fmt() + .event_format(format) + .with_max_level(level) + .with_writer(std::sync::Mutex::new(JsLoggerProducer(tx))) + .init(); + JsLogger(std::sync::Arc::new(tokio::sync::Mutex::new(rx))) + } + + #[napi] + pub async fn message(&self) -> Option { + self.0 + .lock() + .await + .recv() + .await + } +} + +#[derive(Debug, Clone)] +struct JsLoggerProducer(tokio::sync::mpsc::Sender); + +impl std::io::Write for JsLoggerProducer { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // TODO this is a LOSSY logger!! + let _ = self.0.try_send(String::from_utf8_lossy(buf).to_string()); // ignore: logger disconnected or with full buffer + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } +} \ No newline at end of file diff --git a/src/ffi/js/op_cache.rs b/src/ffi/js/op_cache.rs new file mode 100644 index 0000000..67ca47c --- /dev/null +++ b/src/ffi/js/op_cache.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; +use napi_derive::napi; + +pub type OpTuple = (String, u32, String, u32); // buf_path, start, text, end + +#[napi] +pub struct OpCache { + store: HashMap +} + +#[napi] +impl OpCache { + #[napi(constructor)] + pub fn new() -> Self { + OpCache { + store: HashMap::new() + } + } + + #[napi] + pub fn to_string(&self) -> String { + self.store.iter() + .map(|(k, v)| format!("{}x Op(@{} {}:{} '{}')", k.0, v, k.1, k.3, k.2)) + .collect::>() + .join(", ") + } + + #[napi] + pub fn put(&mut self, buf: String, start: u32, text: String, end: u32) -> i32 { + let op = (buf, start, text, end); + match self.store.get_mut(&op) { + Some(val) => { + if *val < 0 { *val = 0 } + *val += 1; + *val + }, + None => { + self.store.insert(op, 1); + return 1; + } + } + } + + #[napi] + pub fn get(&mut self, buf: String, start: u32, text: String, end: u32) -> bool { + let op = (buf, start, text, end); + match self.store.get_mut(&op) { + Some(val) => { + *val -= 1; + *val >= 0 + } + None => { + tracing::warn!("never seen this op: {:?}", op); + self.store.insert(op, -1); + false + }, + } + } +} +//a +//consume a +//a + + + + +#[cfg(test)] +mod test { + #[test] + fn opcache_put_increments_internal_counter() { + let mut op = super::OpCache::new(); + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 1); // 1: did not already contain it + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 2); // 2: already contained it + } + #[test] + fn op_cache_get_checks_count() { + let mut op = super::OpCache::new(); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), false); + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 1); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), true); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), false); + } + #[test] + fn op_cache_get_works_for_multiple_puts() { + let mut op = super::OpCache::new(); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), false); + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 1); + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 2); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), true); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), true); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), false); + } + + #[test] + fn op_cache_different_keys(){ + let mut op = super::OpCache::new(); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), false); + assert_eq!(op.put("default".into(), 0, "hello world".into(), 0), 1); + assert_eq!(op.get("workspace".into(), 0, "hi".into(), 0), false); + assert_eq!(op.put("workspace".into(), 0, "hi".into(), 0), 1); + assert_eq!(op.get("workspace".into(), 0, "hi".into(), 0), true); + assert_eq!(op.get("default".into(), 0, "hello world".into(), 0), true); + } +} \ No newline at end of file diff --git a/src/ffi/js/workspace.rs b/src/ffi/js/workspace.rs new file mode 100644 index 0000000..ce6d924 --- /dev/null +++ b/src/ffi/js/workspace.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use napi_derive::napi; + +use crate::ffi::js::{JsCodempError, buffer::JsBufferController, cursor::JsCursorController}; + + +#[napi] +/// a reference to a codemp workspace +pub struct JsWorkspace(Arc); + +impl From> for JsWorkspace { + fn from(value: Arc) -> Self { + JsWorkspace(value) + } +} + +#[napi] +impl JsWorkspace { + + #[napi] + pub fn id(&self) -> String { + self.0.id() + } + + #[napi] + pub fn filetree(&self) -> Vec { + self.0.filetree() + } + + #[napi] + pub fn cursor(&self) -> JsCursorController { + JsCursorController::from(self.0.cursor()) + } + + #[napi] + pub fn buffer_by_name(&self, path: String) -> Option { + self.0.buffer_by_name(&path).map(|b| JsBufferController::from(b)) + } + + #[napi] + pub async fn create(&self, path: String) -> napi::Result<()> { + Ok(self.0.create(&path).await.map_err(JsCodempError)?) + } + + #[napi] + pub async fn attach(&self, path: String) -> napi::Result { + Ok(JsBufferController::from(self.0.attach(&path).await.map_err(JsCodempError)?)) + } + + /*#[napi] + pub async fn delete(&self, path: String) -> napi::Result<>{ + self.0.delete(&path) + }*/ + +} \ No newline at end of file diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs index 42a5f04..1eb0c82 100644 --- a/src/ffi/mod.rs +++ b/src/ffi/mod.rs @@ -3,3 +3,6 @@ pub mod java; #[cfg(feature = "lua")] pub mod lua; + +#[cfg(feature = "js")] +pub mod js; \ No newline at end of file