diff --git a/crates/core/examples/streaming.rs b/crates/core/examples/streaming.rs
deleted file mode 100644
index 43b0fc01..00000000
--- a/crates/core/examples/streaming.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use liveview_native_core::live_socket::LiveSocket;
-
-#[cfg(target_os = "android")]
-const HOST: &str = "10.0.2.2:4001";
-
-#[cfg(not(target_os = "android"))]
-const HOST: &str = "127.0.0.1:4001";
-
-#[tokio::main]
-async fn main() {
- let _ = env_logger::builder().parse_default_env().try_init();
-
- let url = format!("http://{HOST}/stream");
-
- let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default())
- .await
- .expect("Failed to get liveview socket");
- let live_channel = live_socket
- .join_liveview_channel(None, None)
- .await
- .expect("Failed to join the liveview channel");
- live_channel
- .merge_diffs()
- .await
- .expect("Failed to merge diffs");
-}
diff --git a/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt b/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt
index cb0ec2c2..eaf8d9fe 100644
--- a/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt
+++ b/crates/core/liveview-native-core-jetpack/core/src/test/java/org/phoenixframework/liveview_jetpack/DocumentTest.kt
@@ -5,47 +5,57 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
+import org.phoenixframework.liveviewnative.core.ClientConnectOpts
import org.phoenixframework.liveviewnative.core.ChangeType
-import org.phoenixframework.liveviewnative.core.ConnectOpts
import org.phoenixframework.liveviewnative.core.Document
import org.phoenixframework.liveviewnative.core.DocumentChangeHandler
import org.phoenixframework.liveviewnative.core.LiveFile
-import org.phoenixframework.liveviewnative.core.LiveSocket
+import org.phoenixframework.liveviewnative.core.LiveViewClient
+import org.phoenixframework.liveviewnative.core.LiveViewClientBuilder
import org.phoenixframework.liveviewnative.core.NavOptions
import org.phoenixframework.liveviewnative.core.NodeData
import org.phoenixframework.liveviewnative.core.NodeRef
+import org.phoenixframework.liveviewnative.core.Platform
class SocketTest {
@Test
fun simple_connect() = runTest {
- var live_socket = LiveSocket.connect("http://127.0.0.1:4001/upload", "jetpack", null)
- var live_channel = live_socket.joinLiveviewChannel(null, null)
+ var opts = ClientConnectOpts()
+ var builder = LiveViewClientBuilder()
+
+ builder.setFormat(Platform.Jetpack)
+
+ var client = builder.connect("http://127.0.0.1:4001/upload", opts)
+
// This is a PNG located at crates/core/tests/support/tinycross.png
var base64TileImg =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEdFQog0ycfAgAAAIJJREFUOMulU0EOwCAIK2T/f/LYwWAAgZGtJzS1BbVEuEVAAACCQOsKlkOrEicwgeVz5tC5R1yrDdnKuo6j6J5ydgd+npOUHfaGEJkQq+6cQNVqP1oQiCJxvAjGT3Dn3l1sKpAdfhPhqXP5xDYLXz7SkYUuUNnrcBWULkRlFqZxtvwH8zGCEN6LErUAAAAASUVORK5CYII="
val contents = Base64.getDecoder().decode(base64TileImg)
- val phx_upload_id = live_channel.getPhxUploadId("avatar")
+ val phx_upload_id = client.getPhxUploadId("avatar")
var live_file = LiveFile(contents, "image/png", "avatar", "foobar.png", phx_upload_id)
- live_channel.uploadFile(live_file)
+ client.uploadFiles(listOf(live_file))
}
}
class SocketTestOpts {
@Test
fun connect_with_opts() = runTest {
- var opts = ConnectOpts()
- var live_socket = LiveSocket.connect("http://127.0.0.1:4001/upload", "jetpack", opts)
- var live_channel = live_socket.joinLiveviewChannel(null, null)
+ var opts = ClientConnectOpts()
+ var builder = LiveViewClientBuilder()
+
+ builder.setFormat(Platform.Jetpack)
+
+ var client = builder.connect("http://127.0.0.1:4001/upload", opts)
// This is a PNG located at crates/core/tests/support/tinycross.png
var base64TileImg =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEdFQog0ycfAgAAAIJJREFUOMulU0EOwCAIK2T/f/LYwWAAgZGtJzS1BbVEuEVAAACCQOsKlkOrEicwgeVz5tC5R1yrDdnKuo6j6J5ydgd+npOUHfaGEJkQq+6cQNVqP1oQiCJxvAjGT3Dn3l1sKpAdfhPhqXP5xDYLXz7SkYUuUNnrcBWULkRlFqZxtvwH8zGCEN6LErUAAAAASUVORK5CYII="
val contents = Base64.getDecoder().decode(base64TileImg)
- val phx_upload_id = live_channel.getPhxUploadId("avatar")
+ val phx_upload_id = client.getPhxUploadId("avatar")
var live_file = LiveFile(contents, "image/png", "avatar", "foobar.png", phx_upload_id)
- live_channel.uploadFile(live_file)
+ client.uploadFiles(listOf(live_file))
}
}
@@ -252,9 +262,14 @@ class DocumentTest {
val host = "127.0.0.1:4001"
val url = "http://$host/nav/first_page"
- val liveSocket = LiveSocket.connect(url, "jetpack", null)
- val liveChannel = liveSocket.joinLiveviewChannel(null, null)
- val doc = liveChannel.document()
+ var opts = ClientConnectOpts()
+ var builder = LiveViewClientBuilder()
+
+ builder.setFormat(Platform.Jetpack)
+
+ var client = builder.connect(url, opts)
+
+ val doc = client.document()
val expectedFirstDoc =
"""
@@ -272,9 +287,9 @@ class DocumentTest {
assertEquals(exp.render(), doc.render())
val secondUrl = "http://$host/nav/second_page"
- val secondChannel = liveSocket.navigate(secondUrl, null, NavOptions())
+ client.navigate(secondUrl, NavOptions())
- val secondDoc = secondChannel.document()
+ val secondDoc = client.document()
val expectedSecondDoc =
"""
diff --git a/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift b/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift
index a226c0a9..e2802b47 100644
--- a/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift
+++ b/crates/core/liveview-native-core-swift/Sources/LiveViewNativeCore/Support.swift
@@ -9,7 +9,6 @@ extension LiveViewNativeCore.Payload: @unchecked Sendable {}
extension LiveViewNativeCore.EventPayload: @unchecked Sendable {}
extension LiveViewNativeCore.LiveChannel: @unchecked Sendable {}
-extension LiveViewNativeCore.LiveSocket: @unchecked Sendable {}
extension LiveViewNativeCore.Events: @unchecked Sendable {}
extension LiveViewNativeCore.ChannelStatuses: @unchecked Sendable {}
diff --git a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift
index d4fed0e4..e7a82be3 100644
--- a/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift
+++ b/crates/core/liveview-native-core-swift/Tests/LiveViewNativeCoreTests/LiveViewNativeCoreSocketTests.swift
@@ -11,32 +11,27 @@ let timeout = TimeInterval(30.0)
let connect_url = "http://127.0.0.1:4001/hello"
final class LiveViewNativeCoreSocketTests: XCTestCase {
func testConnect() async throws {
- let live_socket = try await LiveSocket(connect_url, "swiftui", .none)
- let _ = try await live_socket.joinLiveviewChannel(.none, .none)
+ let builder = LiveViewClientBuilder()
+ let client = try await builder.connect(connect_url, ClientConnectOpts())
}
func testConnectWithOpts() async throws {
let headers = [String: String]()
- let options = ConnectOpts(headers: headers)
- let live_socket = try await LiveSocket(connect_url, "swiftui", options)
- let _ = try await live_socket.joinLiveviewChannel(.none, .none)
+ let options = ClientConnectOpts(headers: headers)
+ let builder = LiveViewClientBuilder()
+ let client = try await builder.connect(connect_url, options)
}
func testStatus() async throws {
- let live_socket = try await LiveSocket(connect_url, "swiftui", .none)
- let _ = try await live_socket.joinLiveviewChannel(.none, .none)
- let socket = live_socket.socket()
+ let builder = LiveViewClientBuilder()
+ let client = try await builder.connect(connect_url, ClientConnectOpts())
- var status = socket.status()
+ var status = try client.status()
XCTAssertEqual(status, .connected)
- try await socket.disconnect()
- status = socket.status()
+ try await client.disconnect()
+ status = try client.status()
XCTAssertEqual(status, .disconnected)
-
- try await socket.shutdown()
- status = socket.status()
- XCTAssertEqual(status, .shutDown)
}
func testBasicConnection() async throws {
@@ -111,64 +106,14 @@ let base64TileImg =
let upload_url = "http://127.0.0.1:4001/upload"
final class LiveViewNativeCoreUploadTests: XCTestCase {
func testUpload() async throws {
- let live_socket = try await LiveSocket(upload_url, "swiftui", .none)
- let live_channel = try await live_socket.joinLiveviewChannel(.none, .none)
+ // Using the new LiveViewClient API
+ let builder = LiveViewClientBuilder()
+ let client = try await builder.connect(upload_url, ClientConnectOpts())
let image: Data! = Data(base64Encoded: base64TileImg)
- let phx_id: String! = try live_channel.getPhxUploadId("avatar")
+ let phx_id: String! = try client.getPhxUploadId("avatar")
let live_file = LiveFile(image, "image/png", "avatar", "foobar.png", phx_id)
- try await live_channel.uploadFile(live_file)
+ try await client.uploadFiles([live_file])
}
}
-
-// Test basic navigation flow with LiveSocket
-func testBasicNavFlow() async throws {
- let url = "http://127.0.0.1:4001/nav/first_page"
- let secondUrl = "http://127.0.0.1:4001/nav/second_page"
-
- let liveSocket = try await LiveSocket(url, "swiftui", .none)
- let liveChannel = try await liveSocket.joinLiveviewChannel(.none, .none)
-
- let doc = liveChannel.document()
-
- let expectedFirstDoc = """
-
-
-
- first_page
-
-
-
- NEXT
-
-
-
- """
-
- let exp = try Document.parse(expectedFirstDoc)
-
- XCTAssertEqual(doc.render(), exp.render())
-
- let secondChannel = try await liveSocket.navigate(secondUrl, .none, NavOptions())
-
- let secondDoc = secondChannel.document()
-
- let expectedSecondDoc = """
-
-
-
- second_page
-
-
-
- NEXT
-
-
-
- """
-
- let secondExp = try Document.parse(expectedSecondDoc)
-
- XCTAssertEqual(secondDoc.render(), secondExp.render())
-}
diff --git a/crates/core/src/callbacks.rs b/crates/core/src/callbacks.rs
index d21fdc04..383e6fed 100644
--- a/crates/core/src/callbacks.rs
+++ b/crates/core/src/callbacks.rs
@@ -5,7 +5,7 @@ use phoenix_channels_client::{Socket, SocketStatus};
use crate::dom::{NodeData, NodeRef};
#[cfg(feature = "liveview-channels")]
-use crate::{dom::ffi::Document, live_socket::LiveChannel};
+use crate::{client::LiveChannel, dom::ffi::Document};
/// Provides secure persistent storage for session data like cookies.
/// Implementations should handle platform-specific storage (e.g. NSUserDefaults on iOS)
diff --git a/crates/core/src/client/config.rs b/crates/core/src/client/config.rs
index 787a46c5..2ae9970d 100644
--- a/crates/core/src/client/config.rs
+++ b/crates/core/src/client/config.rs
@@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc};
use phoenix_channels_client::JSON;
-use crate::{callbacks::*, live_socket::Method};
+use crate::callbacks::*;
#[derive(uniffi::Enum, Debug, Clone, Default, Copy)]
pub enum LogLevel {
@@ -135,3 +135,103 @@ impl std::fmt::Debug for LiveViewClientConfiguration {
.finish()
}
}
+
+/// An action taken with respect to the history stack
+/// when [NavCtx::navigate] is executed. defaults to
+/// Push behavior.
+#[derive(uniffi::Enum, Default, Clone)]
+pub enum NavAction {
+ /// Push the navigation event onto the history stack.
+ #[default]
+ Push,
+ /// Replace the current top of the history stack with this navigation event.
+ Replace,
+}
+
+/// Options for calls to [NavCtx::navigate] and the external [LiveViewClient::navigate] function
+/// Slightly different from [NavActionOptions]
+#[derive(Default, uniffi::Record)]
+pub struct NavOptions {
+ /// Additional params to be passed upon joining the liveview channel.
+ #[uniffi(default = None)]
+ pub join_params: Option>,
+ /// see [NavAction], defaults to [NavAction::Push].
+ #[uniffi(default = None)]
+ pub action: Option,
+ /// Ephemeral extra information to be pushed to the even handler.
+ #[uniffi(default = None)]
+ pub extra_event_info: Option>,
+ /// Persistent state, intended to be deserialized for user specific purposes when
+ /// revisiting a given view.
+ #[uniffi(default = None)]
+ pub state: Option>,
+}
+
+#[derive(Default, uniffi::Record)]
+pub struct NavActionOptions {
+ /// Additional params to be passed upon joining the liveview channel.
+ #[uniffi(default = None)]
+ pub join_params: Option>,
+ /// Ephemeral extra information to be pushed to the even handler.
+ #[uniffi(default = None)]
+ pub extra_event_info: Option>,
+}
+
+/// Connection Options for the initial dead render fetch
+#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Default)]
+pub struct DeadRenderFetchOpts {
+ #[uniffi(default = None)]
+ pub headers: Option>,
+ #[uniffi(default = None)]
+ pub body: Option>,
+ #[uniffi(default = None)]
+ pub method: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
+#[repr(u8)]
+pub enum Method {
+ Get = 0,
+ Options,
+ Post,
+ Put,
+ Delete,
+ Head,
+ Trace,
+ Connect,
+ Patch,
+}
+
+use reqwest::Method as ReqMethod;
+impl From for ReqMethod {
+ fn from(val: Method) -> ReqMethod {
+ match val {
+ Method::Options => ReqMethod::OPTIONS,
+ Method::Get => ReqMethod::GET,
+ Method::Post => ReqMethod::POST,
+ Method::Put => ReqMethod::PUT,
+ Method::Delete => ReqMethod::DELETE,
+ Method::Head => ReqMethod::HEAD,
+ Method::Trace => ReqMethod::TRACE,
+ Method::Connect => ReqMethod::CONNECT,
+ Method::Patch => ReqMethod::PATCH,
+ }
+ }
+}
+
+pub struct UploadConfig {
+ pub chunk_size: u64,
+ pub max_file_size: u64,
+ pub max_entries: u64,
+}
+
+/// Defaults from https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3
+impl Default for UploadConfig {
+ fn default() -> Self {
+ Self {
+ chunk_size: 64_000,
+ max_file_size: 8000000,
+ max_entries: 1,
+ }
+ }
+}
diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/client/inner/channel.rs
similarity index 80%
rename from crates/core/src/live_socket/channel.rs
rename to crates/core/src/client/inner/channel.rs
index 94de39e8..f91a7cc4 100644
--- a/crates/core/src/live_socket/channel.rs
+++ b/crates/core/src/client/inner/channel.rs
@@ -1,15 +1,19 @@
-use std::{collections::HashMap, sync::Arc, time::Duration};
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+ time::Duration,
+};
-use futures::{future::FutureExt, pin_mut, select};
-use log::{debug, error};
+use log::{debug, error, trace};
use phoenix_channels_client::{Channel, Event, Number, Payload, Socket, Topic, JSON};
-use super::UploadConfig;
+use super::{dead_render::SessionData, LiveViewClientConfiguration};
use crate::{
callbacks::*,
+ client::UploadConfig,
diff::fragment::{Root, RootDiff},
dom::{ffi::Document as FFiDocument, AttributeName, AttributeValue, Document, Selector},
- error::*,
+ error::{LiveSocketError, *},
};
#[derive(uniffi::Object)]
@@ -169,52 +173,6 @@ impl LiveChannel {
Ok(upload_id)
}
- /// Blocks indefinitely, processing changes to the document using the user provided callback
- /// In `set_event_handler`
- pub async fn merge_diffs(&self) -> Result<(), LiveSocketError> {
- // TODO: This should probably take the event closure to send changes back to swift/kotlin
- let document = self.document.clone();
- let events = self.channel.events();
- let statuses = self.channel.statuses();
- loop {
- let event = events.event().fuse();
- let status = statuses.status().fuse();
-
- pin_mut!(event, status);
-
- select! {
- e = event => {
- let e = e?;
- match e.event {
- Event::Phoenix { phoenix } => {
- error!("Phoenix Event for {phoenix:?} is unimplemented");
- }
- Event::User { user } => {
- if user == "diff" {
- let Payload::JSONPayload { json } = e.payload else {
- error!("Diff was not json!");
- continue;
- };
-
- debug!("PAYLOAD: {json:?}");
- // This function merges and uses the event handler set in `set_event_handler`
- // which will call back into the Swift/Kotlin.
- document.merge_fragment_json(&json.to_string())?;
- }
- }
- };
- }
- new_status = status => {
- match new_status? {
- phoenix_channels_client::ChannelStatus::Left => return Ok(()),
- phoenix_channels_client::ChannelStatus::ShutDown => return Ok(()),
- _ => {},
- }
- }
- };
- }
- }
-
pub fn join_payload(&self) -> Payload {
self.join_payload.clone()
}
@@ -458,3 +416,106 @@ impl LiveChannel {
Ok(())
}
}
+
+const LVN_VSN: &str = "2.0.0";
+const LVN_VSN_KEY: &str = "vsn";
+
+/// TODO: Post refactor turn this into a private constructor on a LiveChannel
+pub async fn join_liveview_channel(
+ socket: &Mutex>,
+ session_data: &Mutex,
+ additional_params: &Option>,
+ redirect: Option,
+ ws_timeout: std::time::Duration,
+) -> Result, LiveSocketError> {
+ let sock = socket.try_lock()?.clone();
+ sock.connect(ws_timeout).await?;
+
+ let sent_join_payload = session_data
+ .try_lock()?
+ .create_join_payload(additional_params, redirect);
+ let topic = Topic::from_string(format!("lv:{}", session_data.try_lock()?.phx_id));
+ let channel = sock.channel(topic, Some(sent_join_payload)).await?;
+
+ let join_payload = channel.join(ws_timeout).await?;
+
+ trace!("Join payload: {join_payload:#?}");
+ let document = match join_payload {
+ Payload::JSONPayload {
+ json: JSON::Object { ref object },
+ } => {
+ if let Some(rendered) = object.get("rendered") {
+ let rendered = rendered.to_string();
+ let root: RootDiff = serde_json::from_str(rendered.as_str())?;
+ trace!("root diff: {root:#?}");
+ let root: Root = root.try_into()?;
+ let rendered: String = root.clone().try_into()?;
+ let mut document = Document::parse(&rendered)?;
+ document.fragment_template = Some(root);
+ Some(document)
+ } else {
+ None
+ }
+ }
+ _ => None,
+ }
+ .ok_or(LiveSocketError::NoDocumentInJoinPayload)?;
+
+ Ok(LiveChannel {
+ channel,
+ join_payload,
+ join_params: additional_params.clone().unwrap_or_default(),
+ socket: socket.try_lock()?.clone(),
+ document: document.into(),
+ timeout: ws_timeout,
+ }
+ .into())
+}
+
+pub async fn join_livereload_channel(
+ config: &LiveViewClientConfiguration,
+ socket: &Mutex>,
+ session_data: &Mutex,
+ cookies: Option>,
+) -> Result, LiveSocketError> {
+ let ws_timeout = Duration::from_millis(config.websocket_timeout);
+
+ let mut url = session_data.try_lock()?.url.clone();
+
+ let websocket_scheme = match url.scheme() {
+ "https" => "wss",
+ "http" => "ws",
+ scheme => {
+ return Err(LiveSocketError::SchemeNotSupported {
+ scheme: scheme.to_string(),
+ })
+ }
+ };
+ let _ = url.set_scheme(websocket_scheme);
+ url.set_path("phoenix/live_reload/socket/websocket");
+ url.query_pairs_mut().append_pair(LVN_VSN_KEY, LVN_VSN);
+
+ let new_socket = Socket::spawn(url.clone(), cookies).await?;
+ new_socket.connect(ws_timeout).await?;
+
+ debug!("Joining live reload channel on url {url}");
+ let channel = new_socket
+ .channel(Topic::from_string("phoenix:live_reload".to_string()), None)
+ .await?;
+
+ debug!("Created channel for live reload socket");
+ let join_payload = channel.join(ws_timeout).await?;
+ let document = Document::empty();
+
+ Ok(LiveChannel {
+ channel,
+ join_params: Default::default(),
+ join_payload,
+ // Q: I copy pasted this from the old implementation,
+ // why use the old socket ?
+ socket: socket.try_lock()?.clone(),
+ document: document.into(),
+ timeout: ws_timeout,
+ }
+ .into())
+}
diff --git a/crates/core/src/client/inner/channel_init.rs b/crates/core/src/client/inner/channel_init.rs
deleted file mode 100644
index 5177e8e6..00000000
--- a/crates/core/src/client/inner/channel_init.rs
+++ /dev/null
@@ -1,119 +0,0 @@
-use std::{
- collections::HashMap,
- sync::{Arc, Mutex},
- time::Duration,
-};
-
-use log::{debug, trace};
-use phoenix_channels_client::{Payload, Socket, Topic, JSON};
-
-use super::LiveViewClientConfiguration;
-use crate::{
- diff::fragment::{Root, RootDiff},
- dom::Document,
- error::LiveSocketError,
- live_socket::{LiveChannel, SessionData},
-};
-
-const LVN_VSN: &str = "2.0.0";
-const LVN_VSN_KEY: &str = "vsn";
-
-/// TODO: Post refactor turn this into a private constructor on a LiveChannel
-pub async fn join_liveview_channel(
- socket: &Mutex>,
- session_data: &Mutex,
- additional_params: &Option>,
- redirect: Option,
- ws_timeout: std::time::Duration,
-) -> Result, LiveSocketError> {
- let sock = socket.try_lock()?.clone();
- sock.connect(ws_timeout).await?;
-
- let sent_join_payload = session_data
- .try_lock()?
- .create_join_payload(additional_params, redirect);
- let topic = Topic::from_string(format!("lv:{}", session_data.try_lock()?.phx_id));
- let channel = sock.channel(topic, Some(sent_join_payload)).await?;
-
- let join_payload = channel.join(ws_timeout).await?;
-
- trace!("Join payload: {join_payload:#?}");
- let document = match join_payload {
- Payload::JSONPayload {
- json: JSON::Object { ref object },
- } => {
- if let Some(rendered) = object.get("rendered") {
- let rendered = rendered.to_string();
- let root: RootDiff = serde_json::from_str(rendered.as_str())?;
- trace!("root diff: {root:#?}");
- let root: Root = root.try_into()?;
- let rendered: String = root.clone().try_into()?;
- let mut document = Document::parse(&rendered)?;
- document.fragment_template = Some(root);
- Some(document)
- } else {
- None
- }
- }
- _ => None,
- }
- .ok_or(LiveSocketError::NoDocumentInJoinPayload)?;
-
- Ok(LiveChannel {
- channel,
- join_payload,
- join_params: additional_params.clone().unwrap_or_default(),
- socket: socket.try_lock()?.clone(),
- document: document.into(),
- timeout: ws_timeout,
- }
- .into())
-}
-
-pub async fn join_livereload_channel(
- config: &LiveViewClientConfiguration,
- socket: &Mutex>,
- session_data: &Mutex,
- cookies: Option>,
-) -> Result, LiveSocketError> {
- let ws_timeout = Duration::from_millis(config.websocket_timeout);
-
- let mut url = session_data.try_lock()?.url.clone();
-
- let websocket_scheme = match url.scheme() {
- "https" => "wss",
- "http" => "ws",
- scheme => {
- return Err(LiveSocketError::SchemeNotSupported {
- scheme: scheme.to_string(),
- })
- }
- };
- let _ = url.set_scheme(websocket_scheme);
- url.set_path("phoenix/live_reload/socket/websocket");
- url.query_pairs_mut().append_pair(LVN_VSN_KEY, LVN_VSN);
-
- let new_socket = Socket::spawn(url.clone(), cookies).await?;
- new_socket.connect(ws_timeout).await?;
-
- debug!("Joining live reload channel on url {url}");
- let channel = new_socket
- .channel(Topic::from_string("phoenix:live_reload".to_string()), None)
- .await?;
-
- debug!("Created channel for live reload socket");
- let join_payload = channel.join(ws_timeout).await?;
- let document = Document::empty();
-
- Ok(LiveChannel {
- channel,
- join_params: Default::default(),
- join_payload,
- // Q: I copy pasted this from the old implementation,
- // why use the old socket ?
- socket: socket.try_lock()?.clone(),
- document: document.into(),
- timeout: ws_timeout,
- }
- .into())
-}
diff --git a/crates/core/src/client/inner/dead_render.rs b/crates/core/src/client/inner/dead_render.rs
new file mode 100644
index 00000000..9d642cbf
--- /dev/null
+++ b/crates/core/src/client/inner/dead_render.rs
@@ -0,0 +1,336 @@
+use core::str;
+use std::{collections::HashMap, time::Duration};
+
+use log::{debug, trace};
+use phoenix_channels_client::{Payload, JSON};
+use reqwest::{header::LOCATION, Client, Url};
+use serde::Serialize;
+
+use crate::{
+ client::{DeadRenderFetchOpts, Method},
+ dom::{AttributeName, Document, ElementName, Selector},
+ error::LiveSocketError,
+};
+
+const MAX_REDIRECTS: usize = 10;
+const LVN_VSN: &str = "2.0.0";
+const LVN_VSN_KEY: &str = "vsn";
+const CSRF_KEY: &str = "_csrf_token";
+const MOUNT_KEY: &str = "_mounts";
+const FMT_KEY: &str = "_format";
+
+/// Static information ascertained from the dead render when connecting.
+#[derive(Clone, Debug)]
+pub struct SessionData {
+ pub connect_opts: DeadRenderFetchOpts,
+ /// Cross site request forgery, security token, sent with dead render.
+ pub csrf_token: String,
+ /// The id of the phoenix channel to join.
+ pub phx_id: String,
+ pub phx_static: String,
+ pub phx_session: String,
+ pub url: Url,
+ /// One of `swift`, `kotlin` or `html` indicating the developer platform.
+ pub format: String,
+ /// An html page that on the web would be used to bootstrap the web socket connection.
+ pub dead_render: Document,
+ pub style_urls: Vec,
+ /// Whether or not the dead render contains a live reload iframe for development mode.
+ pub has_live_reload: bool,
+}
+
+//TODO: Move this into the protocol module when it exists
+/// The expected structure of a json payload send upon joining a liveview channel
+#[derive(Serialize)]
+struct JoinRequestPayload {
+ #[serde(rename = "static")]
+ static_token: String,
+ session: String,
+ #[serde(flatten)]
+ url_or_redirect: UrlOrRedirect,
+ params: HashMap,
+}
+
+#[derive(Serialize)]
+#[serde(untagged)]
+enum UrlOrRedirect {
+ Url { url: String },
+ Redirect { redirect: String },
+}
+
+impl SessionData {
+ pub async fn request(
+ url: &Url,
+ format: &String,
+ timeout: Duration,
+ connect_opts: DeadRenderFetchOpts,
+ client: Client,
+ ) -> Result {
+ // NEED:
+ // these from inside data-phx-main
+ // data-phx-session,
+ // data-phx-static
+ // id
+ //
+ // Top level:
+ // csrf-token
+ // "iframe[src=\"/phoenix/live_reload/frame\"]"
+
+ let (dead_render, url) =
+ get_dead_render(url, format, &connect_opts, timeout, client).await?;
+ //TODO: remove cookies, pull it from the cookie client cookie store.
+
+ log::trace!("dead render retrieved:\n {dead_render}");
+ let csrf_token = dead_render
+ .get_csrf_token()
+ .ok_or(LiveSocketError::CSRFTokenMissing)?;
+
+ let mut phx_id: Option = None;
+ let mut phx_static: Option = None;
+ let mut phx_session: Option = None;
+
+ let main_div_attributes = dead_render
+ .select(Selector::Attribute(AttributeName {
+ name: "data-phx-main".into(),
+ namespace: None,
+ }))
+ .last();
+
+ trace!("main div attributes: {main_div_attributes:?}");
+
+ let main_div_attributes = dead_render
+ .select(Selector::Attribute(AttributeName {
+ namespace: None,
+ name: "data-phx-main".into(),
+ }))
+ .last()
+ .map(|node_ref| dead_render.get(node_ref))
+ .map(|main_div| main_div.attributes())
+ .ok_or(LiveSocketError::PhoenixMainMissing)?;
+
+ for attr in main_div_attributes {
+ if attr.name.name == "id" {
+ phx_id.clone_from(&attr.value)
+ } else if attr.name.name == "data-phx-session" {
+ phx_session.clone_from(&attr.value)
+ } else if attr.name.name == "data-phx-static" {
+ phx_static.clone_from(&attr.value)
+ }
+ }
+ let phx_id = phx_id.ok_or(LiveSocketError::PhoenixIDMissing)?;
+ let phx_static = phx_static.ok_or(LiveSocketError::PhoenixStaticMissing)?;
+ let phx_session = phx_session.ok_or(LiveSocketError::PhoenixSessionMissing)?;
+ trace!("phx_id = {phx_id:?}, session = {phx_session:?}, static = {phx_static:?}");
+
+ // A Style looks like:
+ //
+ let style_urls: Vec = dead_render
+ .select(Selector::Tag(ElementName {
+ namespace: None,
+ name: "Style".into(),
+ }))
+ .map(|node_ref| dead_render.get(node_ref))
+ .filter_map(|node| {
+ node.attributes()
+ .iter()
+ .filter(|attr| attr.name.name == "url")
+ .map(|attr| attr.value.clone())
+ .next_back()
+ .flatten()
+ })
+ .collect();
+
+ // The iframe portion looks like:
+ //
+ let live_reload_iframe: Option = dead_render
+ .select(Selector::Tag(ElementName {
+ namespace: None,
+ name: "iframe".into(),
+ }))
+ .map(|node_ref| dead_render.get(node_ref))
+ .filter_map(|node| {
+ node.attributes()
+ .iter()
+ .filter(|attr| attr.name.name == "src")
+ .map(|attr| attr.value.clone())
+ .next_back()
+ .flatten()
+ })
+ .filter(|iframe_src| iframe_src == "/phoenix/live_reload/frame")
+ .last();
+
+ let has_live_reload = live_reload_iframe.is_some();
+
+ let out = Self {
+ connect_opts,
+ url,
+ format: format.to_string(),
+ csrf_token,
+ phx_id,
+ phx_static,
+ phx_session,
+ dead_render,
+ style_urls,
+ has_live_reload,
+ };
+
+ debug!("Session data successfully acquired");
+ debug!("{out:?}");
+
+ Ok(out)
+ }
+
+ /// reconstruct the live socket url from the session data
+ pub fn get_live_socket_url(&self) -> Result {
+ let websocket_scheme = match self.url.scheme() {
+ "https" => "wss",
+ "http" => "ws",
+ scheme => {
+ return Err(LiveSocketError::SchemeNotSupported {
+ scheme: scheme.to_string(),
+ })
+ }
+ };
+
+ let port = self.url.port().map(|p| format!(":{p}")).unwrap_or_default();
+ let host = self.url.host_str().ok_or(LiveSocketError::NoHostInURL)?;
+
+ let mut websocket_url = Url::parse(&format!("{websocket_scheme}://{host}{port}"))?;
+
+ websocket_url
+ .query_pairs_mut()
+ .append_pair(LVN_VSN_KEY, LVN_VSN)
+ .append_pair(CSRF_KEY, &self.csrf_token)
+ .append_pair(MOUNT_KEY, "0")
+ .append_pair(FMT_KEY, &self.format);
+
+ websocket_url.set_path("/live/websocket");
+
+ debug!("websocket url: {websocket_url}");
+
+ Ok(websocket_url)
+ }
+
+ pub fn create_join_payload(
+ &self,
+ additional_params: &Option>,
+ redirect: Option,
+ ) -> Payload {
+ let mut params = HashMap::new();
+ params.insert(MOUNT_KEY.to_string(), serde_json::json!(0));
+ params.insert(CSRF_KEY.to_string(), serde_json::json!(self.csrf_token));
+ params.insert(FMT_KEY.to_string(), serde_json::json!(self.format));
+ if let Some(join_params) = additional_params {
+ params.extend(
+ join_params
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone().into())),
+ );
+ }
+
+ let payload = JoinRequestPayload {
+ static_token: self.phx_static.clone(),
+ session: self.phx_session.clone(),
+ url_or_redirect: redirect
+ .map(|r| UrlOrRedirect::Redirect { redirect: r })
+ .unwrap_or_else(|| UrlOrRedirect::Url {
+ url: self.url.to_string(),
+ }),
+ params,
+ };
+
+ let json = serde_json::to_value(payload).expect("Serde Error");
+ Payload::JSONPayload { json: json.into() }
+ }
+}
+
+async fn get_dead_render(
+ url: &Url,
+ format: &str,
+ options: &DeadRenderFetchOpts,
+ timeout: Duration,
+ client: Client,
+) -> Result<(Document, Url), LiveSocketError> {
+ let DeadRenderFetchOpts {
+ headers,
+ body,
+ method,
+ } = options;
+
+ let method = method.clone().unwrap_or(Method::Get).into();
+
+ // TODO: Check if params contains all of phx_id, phx_static, phx_session and csrf_token, if
+ // it does maybe we don't need to do a full dead render.
+ let mut url = url.clone();
+ if url.query_pairs().all(|(name, _)| name != FMT_KEY) {
+ url.query_pairs_mut().append_pair(FMT_KEY, format);
+ }
+
+ let headers = (&headers.clone().unwrap_or_default())
+ .try_into()
+ .map_err(|e| LiveSocketError::InvalidHeader {
+ error: format!("{e:?}"),
+ })?;
+
+ let req = reqwest::Request::new(method, url.clone());
+ let builder = reqwest::RequestBuilder::from_parts(client, req);
+
+ let builder = if let Some(body) = body {
+ builder.body(body.clone())
+ } else {
+ builder
+ };
+
+ let (client, request) = builder.timeout(timeout).headers(headers).build_split();
+
+ let mut resp = client.execute(request?).await?;
+
+ for _ in 0..MAX_REDIRECTS {
+ if !resp.status().is_redirection() {
+ log::debug!("{resp:?}");
+ break;
+ }
+ log::debug!("-- REDIRECTING -- ");
+ log::debug!("{resp:?}");
+
+ let mut location = resp
+ .headers()
+ .get(LOCATION)
+ .and_then(|loc| str::from_utf8(loc.as_bytes()).ok())
+ .and_then(|loc| url.join(loc).ok())
+ .ok_or_else(|| LiveSocketError::Request {
+ error: "No valid redirect location in 300 response".into(),
+ })?;
+
+ if location.query_pairs().all(|(name, _)| name != FMT_KEY) {
+ location.query_pairs_mut().append_pair(FMT_KEY, format);
+ }
+
+ resp = client.get(location).send().await?;
+ }
+
+ let status = resp.status();
+
+ //let cookies = jar
+ // .cookies(&url)
+ // .as_ref()
+ // .and_then(|cookie_text| cookie_text.to_str().ok())
+ // .map(|text| {
+ // text.split(";")
+ // .map(str::trim)
+ // .map(String::from)
+ // .collect::>()
+ // })
+ // .unwrap_or_default();
+
+ let url = resp.url().clone();
+ let resp_text = resp.text().await?;
+
+ if !status.is_success() {
+ return Err(LiveSocketError::ConnectionError(resp_text));
+ }
+
+ let dead_render = Document::parse(&resp_text)?;
+ trace!("document:\n{dead_render}\n\n\n");
+ Ok((dead_render, url))
+}
diff --git a/crates/core/src/client/inner/event_loop/state.rs b/crates/core/src/client/inner/event_loop/state.rs
index 45aab70b..a16f6c60 100644
--- a/crates/core/src/client/inner/event_loop/state.rs
+++ b/crates/core/src/client/inner/event_loop/state.rs
@@ -10,13 +10,11 @@ use tokio::select;
use super::{ClientMessage, LiveViewClientState, NetworkEventHandler};
use crate::{
- client::{inner::NavigationSummary, Issuer, LiveChannelStatus},
+ client::{
+ inner::NavigationSummary, Issuer, LiveChannel, LiveChannelStatus, NavAction, NavOptions,
+ },
dom::ffi::{self, Document},
error::LiveSocketError,
- live_socket::{
- navigation::{NavAction, NavOptions},
- LiveChannel,
- },
protocol::{LiveRedirect, RedirectKind},
};
diff --git a/crates/core/src/client/inner/mod.rs b/crates/core/src/client/inner/mod.rs
index 026dadeb..9f1eaad3 100644
--- a/crates/core/src/client/inner/mod.rs
+++ b/crates/core/src/client/inner/mod.rs
@@ -1,5 +1,6 @@
-mod channel_init;
+mod channel;
mod cookie_store;
+mod dead_render;
mod event_loop;
mod logging;
mod navigation;
@@ -10,8 +11,10 @@ use std::{
time::Duration,
};
-use channel_init::*;
+use channel::{join_livereload_channel, join_liveview_channel};
+pub use channel::{LiveChannel, LiveFile};
use cookie_store::PersistentCookieStore;
+use dead_render::SessionData;
use event_loop::EventLoop;
pub(crate) use event_loop::LiveViewClientChannel;
use log::{debug, warn};
@@ -20,15 +23,14 @@ use navigation::NavCtx;
use phoenix_channels_client::{Payload, Socket, SocketStatus, JSON};
use reqwest::{redirect::Policy, Client, Url};
-use super::{ClientConnectOpts, LiveViewClientConfiguration, LogLevel};
+use super::{
+ ClientConnectOpts, DeadRenderFetchOpts, LiveViewClientConfiguration, LogLevel,
+ NavActionOptions, NavOptions,
+};
use crate::{
callbacks::*,
dom::{ffi::Document as FFIDocument, Document},
error::LiveSocketError,
- live_socket::{
- navigation::{NavActionOptions, NavOptions},
- ConnectOpts, LiveChannel, LiveFile, SessionData,
- },
};
pub(crate) struct LiveViewClientState {
@@ -86,7 +88,7 @@ impl LiveViewClientInner {
pub(crate) async fn reconnect(
&self,
url: String,
- opts: ConnectOpts,
+ opts: DeadRenderFetchOpts,
join_params: Option>,
) -> Result<(), LiveSocketError> {
self.state.reconnect(url, opts, join_params).await?;
@@ -274,13 +276,21 @@ impl LiveViewClientState {
let url = Url::parse(&url)?;
let format = config.format.to_string();
- let opts = ConnectOpts {
+ let opts = DeadRenderFetchOpts {
headers: client_opts.headers,
- ..ConnectOpts::default()
+ ..DeadRenderFetchOpts::default()
};
debug!("Retrieving session data from: {url:?}");
- let session_data = SessionData::request(&url, &format, opts, http_client.clone()).await?;
+ let timeout = config.dead_render_timeout;
+ let session_data = SessionData::request(
+ &url,
+ &format,
+ Duration::from_millis(timeout),
+ opts,
+ http_client.clone(),
+ )
+ .await?;
let cookies = cookie_store.get_cookie_list(&url);
@@ -341,15 +351,17 @@ impl LiveViewClientState {
pub async fn reconnect(
&self,
url: String,
- opts: ConnectOpts,
+ opts: DeadRenderFetchOpts,
join_params: Option>,
) -> Result<(), LiveSocketError> {
debug!("Reestablishing connection with settings: url: {url:?}, opts: {opts:?}");
+ let timeout = self.config.dead_render_timeout;
let url = Url::parse(&url)?;
let new_session = SessionData::request(
&url,
&self.config.format.to_string(),
+ Duration::from_millis(timeout),
opts,
self.http_client.clone(),
)
@@ -466,10 +478,12 @@ impl LiveViewClientState {
let url = Url::parse(¤t.url)?;
let format = self.config.format.to_string();
+ let timeout = self.config.dead_render_timeout;
let new_session_data = SessionData::request(
&url,
&format,
+ Duration::from_millis(timeout),
Default::default(),
self.http_client.clone(),
)
@@ -477,8 +491,9 @@ impl LiveViewClientState {
let websocket_url = new_session_data.get_live_socket_url()?;
- let new_socket =
- Socket::spawn(websocket_url, Some(new_session_data.cookies.clone())).await?;
+ let cookies = self.cookie_store.get_cookie_list(&new_session_data.url);
+
+ let new_socket = Socket::spawn(websocket_url, cookies).await?;
let sock = self.socket.try_lock()?.clone();
sock.shutdown()
diff --git a/crates/core/src/client/inner/navigation.rs b/crates/core/src/client/inner/navigation.rs
index e21572fe..240b4bc4 100644
--- a/crates/core/src/client/inner/navigation.rs
+++ b/crates/core/src/client/inner/navigation.rs
@@ -4,7 +4,7 @@ use reqwest::Url;
use crate::{
callbacks::*,
- live_socket::navigation::{NavAction, NavOptions},
+ client::{NavAction, NavOptions},
};
#[derive(Clone, Default)]
@@ -35,6 +35,30 @@ pub struct NavCtx {
navigation_event_handler: HandlerInternal,
}
+impl NavEvent {
+ fn new(
+ event: NavEventType,
+ to: NavHistoryEntry,
+ from: Option,
+ info: Option>,
+ ) -> Self {
+ let new_url = Url::parse(&to.url).ok();
+ let old_url = from.as_ref().and_then(|dest| Url::parse(&dest.url).ok());
+
+ let same_document = old_url
+ .zip(new_url)
+ .is_some_and(|(old, new)| old.path() == new.path());
+
+ NavEvent {
+ event,
+ same_document,
+ from,
+ to,
+ info,
+ }
+ }
+}
+
impl NavCtx {
/// Navigate to `url` with behavior and metadata specified in `opts`.
/// Returns the current history ID if changed
@@ -292,6 +316,7 @@ mod test {
use std::sync::Mutex;
use super::*;
+ use crate::client::NavOptions;
// Mock event handler used to validate the internal
// navigation objects state.
@@ -329,18 +354,16 @@ mod test {
let url = Url::parse(url_str).expect("URL failed to parse");
ctx.navigate(url, NavOptions::default(), true);
+ let ev = handler.last_event().expect("Missing Event");
assert_eq!(
- NavEvent {
- event: NavEventType::Push,
- to: NavHistoryEntry {
- state: None,
- id: 1,
- url: url_str.to_string(),
- },
- ..NavEvent::empty()
+ NavHistoryEntry {
+ state: None,
+ id: 1,
+ url: url_str.to_string(),
},
- handler.last_event().expect("Missing Event")
+ ev.to
);
+ assert_eq!(NavEventType::Push, ev.event);
}
#[test]
@@ -373,7 +396,9 @@ mod test {
url: first_url_str.to_string(),
}
.into(),
- ..NavEvent::empty()
+ event: NavEventType::Push,
+ same_document: false,
+ info: None,
},
handler.last_event().expect("Missing Event")
);
@@ -394,7 +419,9 @@ mod test {
url: url_str.to_string(),
}
.into(),
- ..NavEvent::empty()
+ event: NavEventType::Push,
+ same_document: false,
+ info: None,
},
handler.last_event().expect("Missing Event")
);
diff --git a/crates/core/src/client/mod.rs b/crates/core/src/client/mod.rs
index 66b830f3..03dc3685 100644
--- a/crates/core/src/client/mod.rs
+++ b/crates/core/src/client/mod.rs
@@ -12,6 +12,7 @@ use std::{
use config::*;
use futures::future::try_join_all;
use inner::LiveViewClientInner;
+pub use inner::{LiveChannel, LiveFile};
use phoenix_channels_client::{Payload, SocketStatus, JSON};
use reqwest::header::CONTENT_TYPE;
@@ -19,10 +20,6 @@ use crate::{
callbacks::*,
dom::ffi::{self},
error::LiveSocketError,
- live_socket::{
- navigation::{NavActionOptions, NavOptions},
- ConnectOpts, LiveChannel, LiveFile, Method,
- },
};
const CSRF_HEADER: &str = "x-csrf-token";
@@ -165,11 +162,10 @@ impl LiveViewClient {
url: String,
client_opts: ClientConnectOpts,
) -> Result<(), LiveSocketError> {
- let opts = ConnectOpts {
+ let opts = DeadRenderFetchOpts {
headers: client_opts.headers,
body: client_opts.request_body,
method: client_opts.method,
- ..Default::default()
};
self.inner
@@ -216,11 +212,10 @@ impl LiveViewClient {
);
header_map.insert(CSRF_HEADER.to_string(), self.csrf_token()?);
- let opts = ConnectOpts {
+ let opts = DeadRenderFetchOpts {
headers,
body: Some(form_data.into_bytes()),
method: Some(Method::Post),
- timeout_ms: 30_000, // Actually unused, should remove at one point
};
self.inner.reconnect(url, opts, join_params).await?;
diff --git a/crates/core/src/client/tests/lifecycle.rs b/crates/core/src/client/tests/lifecycle.rs
index 43202d65..1e77b827 100644
--- a/crates/core/src/client/tests/lifecycle.rs
+++ b/crates/core/src/client/tests/lifecycle.rs
@@ -7,11 +7,10 @@ use serde_json::json;
use super::{json_payload, HOST};
use crate::{
client::{
- HandlerResponse, Issuer, LiveChannelStatus, LiveViewClientConfiguration, NavEvent,
- NavEventHandler, NavEventType, NavHistoryEntry, NetworkEventHandler, Platform,
+ HandlerResponse, Issuer, LiveChannel, LiveChannelStatus, LiveViewClientConfiguration,
+ NavEvent, NavEventHandler, NavEventType, NavHistoryEntry, NetworkEventHandler, Platform,
},
dom::{self},
- live_socket::LiveChannel,
LiveViewClient,
};
diff --git a/crates/core/src/client/tests/upload.rs b/crates/core/src/client/tests/upload.rs
index 1efffcfd..3d1a6519 100644
--- a/crates/core/src/client/tests/upload.rs
+++ b/crates/core/src/client/tests/upload.rs
@@ -3,9 +3,8 @@ use tempfile::tempdir;
use super::HOST;
use crate::{
- client::{LiveViewClientConfiguration, Platform},
+ client::{LiveFile, LiveViewClientConfiguration, Platform},
error::{LiveSocketError, UploadError},
- live_socket::LiveFile,
LiveViewClient,
};
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 98fb4892..e5e7ac53 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -13,9 +13,6 @@ mod protocol;
mod error;
-#[cfg(feature = "liveview-channels")]
-pub mod live_socket;
-
mod interner;
pub use self::interner::{symbols, InternedString, Symbol};
diff --git a/crates/core/src/live_socket/mod.rs b/crates/core/src/live_socket/mod.rs
deleted file mode 100644
index 05afd267..00000000
--- a/crates/core/src/live_socket/mod.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-mod channel;
-pub mod navigation;
-mod socket;
-
-#[cfg(test)]
-mod tests;
-
-pub use channel::{LiveChannel, LiveFile};
-pub use socket::{ConnectOpts, LiveSocket, Method, SessionData};
-
-pub struct UploadConfig {
- chunk_size: u64,
- max_file_size: u64,
- max_entries: u64,
-}
-
-/// Defaults from https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3
-impl Default for UploadConfig {
- fn default() -> Self {
- Self {
- chunk_size: 64_000,
- max_file_size: 8000000,
- max_entries: 1,
- }
- }
-}
diff --git a/crates/core/src/live_socket/navigation/ffi.rs b/crates/core/src/live_socket/navigation/ffi.rs
deleted file mode 100644
index cdd246c1..00000000
--- a/crates/core/src/live_socket/navigation/ffi.rs
+++ /dev/null
@@ -1,286 +0,0 @@
-//! # FFI Navigation Types
-//!
-//! Types and utilities for interacting with the navigation API for the FFI api consumers.
-use std::collections::HashMap;
-
-use phoenix_channels_client::{Payload, Socket, JSON};
-#[cfg(not(test))]
-use reqwest::cookie::Jar;
-use reqwest::{redirect::Policy, Url};
-
-use crate::callbacks::*;
-
-const RETRY_REASONS: &[&str] = &["stale", "unauthorized"];
-
-/// An action taken with respect to the history stack
-/// when [NavCtx::navigate] is executed. defaults to
-/// Push behavior.
-#[derive(uniffi::Enum, Default, Clone)]
-pub enum NavAction {
- /// Push the navigation event onto the history stack.
- #[default]
- Push,
- /// Replace the current top of the history stack with this navigation event.
- Replace,
-}
-
-/// Options for calls to [NavCtx::navigate]
-#[derive(Default, uniffi::Record)]
-pub struct NavOptions {
- /// Additional params to be passed upon joining the liveview channel.
- #[uniffi(default = None)]
- pub join_params: Option>,
- /// see [NavAction], defaults to [NavAction::Push].
- #[uniffi(default = None)]
- pub action: Option,
- /// Ephemeral extra information to be pushed to the even handler.
- #[uniffi(default = None)]
- pub extra_event_info: Option>,
- /// Persistent state, intended to be deserialized for user specific purposes when
- /// revisiting a given view.
- #[uniffi(default = None)]
- pub state: Option>,
-}
-
-#[derive(Default, uniffi::Record)]
-pub struct NavActionOptions {
- /// Additional params to be passed upon joining the liveview channel.
- #[uniffi(default = None)]
- pub join_params: Option>,
- /// Ephemeral extra information to be pushed to the even handler.
- #[uniffi(default = None)]
- pub extra_event_info: Option>,
-}
-
-impl NavEvent {
- pub fn new(
- event: NavEventType,
- to: NavHistoryEntry,
- from: Option,
- info: Option>,
- ) -> Self {
- let new_url = Url::parse(&to.url).ok();
- let old_url = from.as_ref().and_then(|dest| Url::parse(&dest.url).ok());
-
- let same_document = old_url
- .zip(new_url)
- .is_some_and(|(old, new)| old.path() == new.path());
-
- NavEvent {
- event,
- same_document,
- from,
- to,
- info,
- }
- }
-}
-
-use super::{LiveSocket, NavCtx};
-#[cfg(not(test))]
-use crate::live_socket::socket::COOKIE_JAR;
-#[cfg(test)]
-use crate::live_socket::socket::TEST_COOKIE_JAR;
-use crate::{
- error::LiveSocketError,
- live_socket::{socket::SessionData, LiveChannel},
-};
-
-impl LiveSocket {
- /// Tries to navigate to the current item in the NavCtx.
- /// changing state in one fell swoop if initialilization succeeds
- async fn try_nav(
- &self,
- join_params: Option>,
- ) -> Result {
- let current = self
- .current()
- .ok_or(LiveSocketError::NavigationImpossible)?;
-
- let url = Url::parse(¤t.url)?;
-
- match self
- .join_liveview_channel(join_params.clone(), url.to_string().into())
- .await
- {
- // A join rejection should be ameliorated by reconnecting
- Err(LiveSocketError::JoinRejection {
- error:
- Payload::JSONPayload {
- json: JSON::Object { object },
- },
- }) => {
- // retry on { "reason" : "stale" } and unauthorized
- if object
- .get("reason")
- .and_then(|r| match r {
- JSON::Str { string } => Some(string),
- _ => None,
- })
- .is_none_or(|reason| !RETRY_REASONS.contains(&reason.as_str()))
- {
- return Err(LiveSocketError::JoinRejection {
- error: Payload::JSONPayload {
- json: JSON::Object { object },
- },
- });
- }
-
- let format = self.session_data.try_lock()?.format.clone();
- let options = self.session_data.try_lock()?.connect_opts.clone();
-
- //TODO: punt the to an argument. move this on to the LiveViewClient
- #[cfg(not(test))]
- let jar = COOKIE_JAR.get_or_init(|| Jar::default().into());
-
- #[cfg(test)]
- let jar = TEST_COOKIE_JAR.with(|inner| inner.clone());
-
- let client = reqwest::Client::builder()
- .cookie_provider(jar.clone())
- .redirect(Policy::none())
- .build()?;
-
- let session_data = SessionData::request(&url, &format, options, client).await?;
- let websocket_url = session_data.get_live_socket_url()?;
- let socket =
- Socket::spawn(websocket_url, Some(session_data.cookies.clone())).await?;
-
- self.socket()
- .shutdown()
- .await
- .map_err(|_| LiveSocketError::DisconnectionError)?;
-
- *self.socket.try_lock()? = socket;
- *self.session_data.try_lock()? = session_data;
- self.join_liveview_channel(join_params, None).await
- }
- // Just reconnect or bail
- Ok(chan) => Ok(chan),
- Err(e) => Err(e),
- }
- }
-
- /// calls [Self::try_nav] rolling back to a previous navigation state on failure.
- async fn try_nav_outer(
- &self,
- join_params: Option>,
- nav_action: F,
- ) -> Result
- where
- F: FnOnce(&mut NavCtx) -> Option,
- {
- // Tries to complete the nav action, updating state,
- // this may be cancelled by the user or by the navigation
- // being impossiblem, such as back navigation on an empty stack.
- let new_id = {
- let mut ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_action(&mut ctx)
- };
-
- if new_id.is_none() {
- return Err(LiveSocketError::NavigationImpossible);
- };
-
- // actually return the update liveview channel
- match self.try_nav(join_params).await {
- Ok(channel) => Ok(channel),
- Err(e) => Err(e),
- }
- }
-}
-
-#[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))]
-impl LiveSocket {
- /// Navigates the socket to a new URL, reusing the previous channel's connection parameters, closing it safely,
- /// and emitting a new [LiveChannel]
- pub async fn navigate(
- &self,
- url: String,
- join_params: Option>,
- opts: NavOptions,
- ) -> Result {
- let url = Url::parse(&url)?;
- self.try_nav_outer(join_params, |ctx| ctx.navigate(url, opts, true))
- .await
- }
-
- /// Reload the current channel.
- pub async fn reload(
- &self,
- join_params: Option>,
- info: Option>,
- ) -> Result {
- self.try_nav_outer(join_params, |ctx| ctx.reload(info, true))
- .await
- }
-
- /// Navigates the socket to the previous entry in the stack.
- pub async fn back(
- &self,
- join_params: Option>,
- info: Option>,
- ) -> Result {
- self.try_nav_outer(join_params, |ctx| ctx.back(info, true))
- .await
- }
-
- /// Navigates the socket to the next entry in the stack. Reuses the previous channel's connection parameters, closes it safely,
- /// and emits a new [LiveChannel]
- pub async fn forward(
- &self,
- join_params: Option>,
- info: Option>,
- ) -> Result {
- self.try_nav_outer(join_params, |ctx| ctx.forward(info, true))
- .await
- }
-
- /// Navigates the socket to the specified entry in the stack, preserving the stack. Resuses the previous channel's connection parameters, closes it safely,
- /// and emits a new [LiveChannel]
- pub async fn traverse_to(
- &self,
- id: HistoryId,
- join_params: Option>,
- info: Option>,
- ) -> Result {
- self.try_nav_outer(join_params, |ctx| ctx.traverse_to(id, info, true))
- .await
- }
-
- /// Returns whether navigation backward in history is possible.
- pub fn can_go_back(&self) -> bool {
- let nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.can_go_back()
- }
-
- /// Returns whether navigation forward in history is possible.
- pub fn can_go_forward(&self) -> bool {
- let nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.can_go_forward()
- }
-
- /// Returns whether navigation to the specified history entry ID is possible.
- pub fn can_traverse_to(&self, id: HistoryId) -> bool {
- let nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.can_traverse_to(id)
- }
-
- /// Returns a list of all history entries in traversal sequence order.
- pub fn get_entries(&self) -> Vec {
- let nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.entries()
- }
-
- /// Returns the current history entry, if one exists.
- pub fn current(&self) -> Option {
- let nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.current()
- }
-
- /// Sets the handler for navigation events.
- pub fn set_event_handler(&self, handler: Box) {
- let mut nav_ctx = self.navigation_ctx.lock().expect("lock poison");
- nav_ctx.set_event_handler(handler.into())
- }
-}
diff --git a/crates/core/src/live_socket/navigation/mod.rs b/crates/core/src/live_socket/navigation/mod.rs
deleted file mode 100644
index 202aa1fb..00000000
--- a/crates/core/src/live_socket/navigation/mod.rs
+++ /dev/null
@@ -1,268 +0,0 @@
-mod ffi;
-
-use std::sync::Arc;
-
-pub use ffi::*;
-use reqwest::Url;
-
-use super::socket::LiveSocket;
-use crate::callbacks::*;
-
-#[derive(Clone, Default)]
-struct HandlerInternal(pub Option>);
-
-impl std::fmt::Debug for HandlerInternal {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if self.0.is_some() {
- write!(f, "Handler Active")?;
- } else {
- write!(f, "No Handler Present")?;
- };
- Ok(())
- }
-}
-
-/// The internal navigation context.
-/// handles the history state of the visited views.
-#[derive(Debug, Clone, Default)]
-pub struct NavCtx {
- /// Previously visited views
- history: Vec,
- /// Views that are "forward" in history
- future: Vec,
- /// monotonically increasing ID for `NavHistoryEntry`
- id_source: HistoryId,
- /// user provided callback
- navigation_event_handler: HandlerInternal,
-}
-
-impl NavCtx {
- /// Navigate to `url` with behavior and metadata specified in `opts`.
- /// Returns the current history ID if changed
- pub fn navigate(&mut self, url: Url, opts: NavOptions, emit_event: bool) -> Option {
- let action = opts.action.clone();
- let next_dest = self.speculative_next_dest(&url, opts.state.clone());
- let next_id = next_dest.id;
-
- let event = {
- let new_dest = next_dest.clone();
- let old_dest = self.current();
- let event = match opts.action {
- Some(NavAction::Replace) => NavEventType::Replace,
- _ => NavEventType::Push,
- };
-
- NavEvent::new(event, new_dest, old_dest, opts.extra_event_info)
- };
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {}
- HandlerResponse::PreventDefault => return None,
- };
-
- match action {
- Some(NavAction::Replace) => self.replace_entry(next_dest),
- None | Some(NavAction::Push) => self.push_entry(next_dest),
- }
-
- // successful navigation invalidates previously coalesced state from
- // calls to `back`
- self.future.clear();
- Some(next_id)
- }
-
- // Returns true if the navigator can go back one entry.
- pub fn can_go_back(&self) -> bool {
- self.history.len() >= 2
- }
-
- // Returns true if the navigator can go forward one entry.
- pub fn can_go_forward(&self) -> bool {
- !self.future.is_empty()
- }
-
- // Returns true if the `id` is tracked in the navigation context.
- pub fn can_traverse_to(&self, id: HistoryId) -> bool {
- let hist = self.history.iter().find(|ent| ent.id == id);
- let fut = self.future.iter().find(|ent| ent.id == id);
- hist.or(fut).is_some()
- }
-
- // Returns all of the tracked history entries by cloning them.
- // They are in traversal sequence order, with no guarantees about
- // the position of the current entry.
- pub fn entries(&self) -> Vec {
- self.history
- .iter()
- .chain(self.future.iter().rev())
- .cloned()
- .collect()
- }
-
- /// Calls the handler for reload events
- pub fn reload(&mut self, info: Option>, emit_event: bool) -> Option {
- let current = self.current()?;
- let id = current.id;
-
- let event = NavEvent::new(NavEventType::Reload, current.clone(), current.into(), info);
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {}
- HandlerResponse::PreventDefault => return None,
- };
-
- Some(id)
- }
-
- /// Navigates back one step in the stack, returning the id of the new
- /// current entry if successful.
- /// This function fails if there is no current
- /// page or if there are no items in history and returns [None].
- pub fn back(&mut self, info: Option>, emit_event: bool) -> Option {
- if !self.can_go_back() {
- log::warn!("Attempted `back` navigation without at minimum two entries.");
- return None;
- }
-
- let previous = self.current()?;
-
- let next = self.history[self.history.len() - 2].clone();
-
- let event = {
- let new_dest = next.clone();
- let old_dest = previous.clone();
- NavEvent::new(NavEventType::Push, new_dest, Some(old_dest), info)
- };
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {
- let previous = self.history.pop()?;
- let out = Some(next.id);
- self.future.push(previous);
- out
- }
- HandlerResponse::PreventDefault => None,
- }
- }
-
- /// Navigate one step forward, fails if there is not at least one
- /// item in the history and future stacks.
- pub fn forward(&mut self, info: Option>, emit_event: bool) -> Option {
- if !self.can_go_forward() {
- log::warn!(
- "Attempted `future` navigation with an no current location or no next entry."
- );
- return None;
- }
-
- let next = self.future.last().cloned()?;
- let previous = self.current();
-
- let event = NavEvent::new(NavEventType::Push, next, previous, info);
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {
- let next = self.future.pop()?;
- let out = Some(next.id);
- self.push_entry(next);
- out
- }
- HandlerResponse::PreventDefault => None,
- }
- }
-
- pub fn traverse_to(
- &mut self,
- id: HistoryId,
- info: Option>,
- emit_event: bool,
- ) -> Option {
- if !self.can_traverse_to(id) {
- log::warn!("Attempted to traverse to an untracked ID!");
- return None;
- }
-
- let old_dest = self.current()?;
- let in_hist = self.history.iter().position(|ent| ent.id == id);
- if let Some(entry) = in_hist {
- let new_dest = self.history[entry].clone();
-
- let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info);
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {}
- HandlerResponse::PreventDefault => return None,
- };
-
- // All entries except the target
- let ext = self.history.drain(entry + 1..);
- self.future.extend(ext.rev());
- return Some(id);
- }
-
- let in_fut = self.future.iter().position(|ent| ent.id == id);
- if let Some(entry) = in_fut {
- let new_dest = self.future[entry].clone();
-
- let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info);
-
- match self.handle_event(event, emit_event) {
- HandlerResponse::Default => {}
- HandlerResponse::PreventDefault => return None,
- };
-
- // All entries including the target, which will be at the front.
- let ext = self.future.drain(entry..);
- self.history.extend(ext.rev());
- return Some(id);
- }
-
- None
- }
-
- /// Returns the current history entry and state
- pub fn current(&self) -> Option {
- self.history.last().cloned()
- }
-
- fn replace_entry(&mut self, history_entry: NavHistoryEntry) {
- if let Some(last) = self.history.last_mut() {
- self.id_source += 1;
-
- *last = history_entry
- } else {
- self.push_entry(history_entry)
- }
- }
-
- fn push_entry(&mut self, history_entry: NavHistoryEntry) {
- self.id_source += 1;
- self.history.push(history_entry);
- }
-
- pub fn set_event_handler(&mut self, handler: Arc) {
- self.navigation_event_handler.0 = Some(handler)
- }
-
- pub fn handle_event(&mut self, event: NavEvent, emit_event: bool) -> HandlerResponse {
- if !emit_event {
- return HandlerResponse::Default;
- }
-
- if let Some(handler) = self.navigation_event_handler.0.as_ref() {
- handler.handle_event(event)
- } else {
- HandlerResponse::Default
- }
- }
-
- /// create a new destination if one would be added to history, this includes
- /// the next unique ID that would be issued.
- fn speculative_next_dest(&self, url: &Url, state: Option>) -> NavHistoryEntry {
- NavHistoryEntry {
- id: self.id_source + 1,
- url: url.to_string(),
- state,
- }
- }
-}
diff --git a/crates/core/src/live_socket/socket.rs b/crates/core/src/live_socket/socket.rs
deleted file mode 100644
index 3921c423..00000000
--- a/crates/core/src/live_socket/socket.rs
+++ /dev/null
@@ -1,684 +0,0 @@
-use core::str;
-use std::{
- collections::HashMap,
- sync::{Arc, Mutex},
- time::Duration,
-};
-
-use log::{debug, trace};
-use phoenix_channels_client::{url::Url, Payload, Socket, SocketStatus, Topic, JSON};
-use reqwest::{
- cookie::{CookieStore, Jar},
- header::{HeaderMap, LOCATION, SET_COOKIE},
- redirect::Policy,
- Client, Method as ReqMethod,
-};
-use serde::Serialize;
-
-use super::navigation::{NavCtx, NavOptions};
-pub use super::LiveChannel;
-use crate::{
- diff::fragment::{Root, RootDiff},
- dom::{ffi::Document as FFiDocument, AttributeName, Document, ElementName, Selector},
- error::LiveSocketError,
-};
-
-#[macro_export]
-macro_rules! lock {
- ($mutex:expr) => {
- $mutex.lock().expect("Failed to acquire lock")
- };
- ($mutex:expr, $msg:expr) => {
- $mutex.lock().expect($msg)
- };
-}
-
-#[cfg(not(test))]
-use std::sync::OnceLock;
-#[cfg(not(test))]
-pub static COOKIE_JAR: OnceLock> = OnceLock::new();
-
-// Each test runs in a separate thread and should make requests
-// as if it is an isolated session.
-#[cfg(test)]
-thread_local! {
- pub static TEST_COOKIE_JAR: Arc = Arc::default();
-}
-
-const MAX_REDIRECTS: usize = 10;
-const LVN_VSN: &str = "2.0.0";
-const LVN_VSN_KEY: &str = "vsn";
-const CSRF_KEY: &str = "_csrf_token";
-const MOUNT_KEY: &str = "_mounts";
-const FMT_KEY: &str = "_format";
-
-#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
-#[repr(u8)]
-pub enum Method {
- Get = 0,
- Options,
- Post,
- Put,
- Delete,
- Head,
- Trace,
- Connect,
- Patch,
-}
-
-impl From for ReqMethod {
- fn from(val: Method) -> ReqMethod {
- match val {
- Method::Options => ReqMethod::OPTIONS,
- Method::Get => ReqMethod::GET,
- Method::Post => ReqMethod::POST,
- Method::Put => ReqMethod::PUT,
- Method::Delete => ReqMethod::DELETE,
- Method::Head => ReqMethod::HEAD,
- Method::Trace => ReqMethod::TRACE,
- Method::Connect => ReqMethod::CONNECT,
- Method::Patch => ReqMethod::PATCH,
- }
- }
-}
-
-// If you change this also change the
-// default below in the proc macro
-const DEFAULT_TIMEOUT: u64 = 30_000;
-
-#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
-pub struct ConnectOpts {
- #[uniffi(default = None)]
- pub headers: Option>,
- #[uniffi(default = None)]
- pub body: Option>,
- #[uniffi(default = None)]
- pub method: Option,
- #[uniffi(default = 30_000)]
- pub timeout_ms: u64,
-}
-
-impl Default for ConnectOpts {
- fn default() -> Self {
- Self {
- headers: None,
- body: None,
- method: None,
- timeout_ms: DEFAULT_TIMEOUT,
- }
- }
-}
-
-/// Static information ascertained from the dead render when connecting.
-#[derive(Clone, Debug)]
-pub struct SessionData {
- /// reply headers
- pub join_headers: HashMap>,
- pub connect_opts: ConnectOpts,
- /// Cross site request forgery, security token, sent with dead render.
- pub csrf_token: String,
- /// The id of the phoenix channel to join.
- pub phx_id: String,
- pub phx_static: String,
- pub phx_session: String,
- pub url: Url,
- /// One of `swift`, `kotlin` or `html` indicating the developer platform.
- pub format: String,
- /// An html page that on the web would be used to bootstrap the web socket connection.
- pub dead_render: Document,
- pub style_urls: Vec,
- /// Whether or not the dead render contains a live reload iframe for development mode.
- pub has_live_reload: bool,
- /// A list of cookies sent over with the dead render.
- pub cookies: Vec,
-}
-
-//TODO: Move this into the protocol module when it exists
-/// The expected structure of a json payload send upon joining a liveview channel
-#[derive(Serialize)]
-struct JoinRequestPayload {
- #[serde(rename = "static")]
- static_token: String,
- session: String,
- #[serde(flatten)]
- url_or_redirect: UrlOrRedirect,
- params: HashMap,
-}
-
-#[derive(Serialize)]
-#[serde(untagged)]
-enum UrlOrRedirect {
- Url { url: String },
- Redirect { redirect: String },
-}
-
-impl SessionData {
- pub async fn request(
- url: &Url,
- format: &String,
- connect_opts: ConnectOpts,
- client: Client,
- ) -> Result {
- // NEED:
- // these from inside data-phx-main
- // data-phx-session,
- // data-phx-static
- // id
- //
- // Top level:
- // csrf-token
- // "iframe[src=\"/phoenix/live_reload/frame\"]"
-
- let (dead_render, cookies, url, header_map) =
- LiveSocket::get_dead_render(url, format, &connect_opts, client).await?;
- //TODO: remove cookies, pull it from the cookie client cookie store.
-
- log::trace!("dead render retrieved:\n {dead_render}");
- let csrf_token = dead_render
- .get_csrf_token()
- .ok_or(LiveSocketError::CSRFTokenMissing)?;
-
- let mut phx_id: Option = None;
- let mut phx_static: Option = None;
- let mut phx_session: Option = None;
-
- let main_div_attributes = dead_render
- .select(Selector::Attribute(AttributeName {
- name: "data-phx-main".into(),
- namespace: None,
- }))
- .last();
-
- trace!("main div attributes: {main_div_attributes:?}");
-
- let main_div_attributes = dead_render
- .select(Selector::Attribute(AttributeName {
- namespace: None,
- name: "data-phx-main".into(),
- }))
- .last()
- .map(|node_ref| dead_render.get(node_ref))
- .map(|main_div| main_div.attributes())
- .ok_or(LiveSocketError::PhoenixMainMissing)?;
-
- for attr in main_div_attributes {
- if attr.name.name == "id" {
- phx_id.clone_from(&attr.value)
- } else if attr.name.name == "data-phx-session" {
- phx_session.clone_from(&attr.value)
- } else if attr.name.name == "data-phx-static" {
- phx_static.clone_from(&attr.value)
- }
- }
- let phx_id = phx_id.ok_or(LiveSocketError::PhoenixIDMissing)?;
- let phx_static = phx_static.ok_or(LiveSocketError::PhoenixStaticMissing)?;
- let phx_session = phx_session.ok_or(LiveSocketError::PhoenixSessionMissing)?;
- trace!("phx_id = {phx_id:?}, session = {phx_session:?}, static = {phx_static:?}");
-
- // A Style looks like:
- //
- let style_urls: Vec = dead_render
- .select(Selector::Tag(ElementName {
- namespace: None,
- name: "Style".into(),
- }))
- .map(|node_ref| dead_render.get(node_ref))
- .filter_map(|node| {
- node.attributes()
- .iter()
- .filter(|attr| attr.name.name == "url")
- .map(|attr| attr.value.clone())
- .next_back()
- .flatten()
- })
- .collect();
-
- // The iframe portion looks like:
- //
- let live_reload_iframe: Option = dead_render
- .select(Selector::Tag(ElementName {
- namespace: None,
- name: "iframe".into(),
- }))
- .map(|node_ref| dead_render.get(node_ref))
- .filter_map(|node| {
- node.attributes()
- .iter()
- .filter(|attr| attr.name.name == "src")
- .map(|attr| attr.value.clone())
- .next_back()
- .flatten()
- })
- .filter(|iframe_src| iframe_src == "/phoenix/live_reload/frame")
- .last();
-
- let has_live_reload = live_reload_iframe.is_some();
-
- let mut join_headers = HashMap::new();
-
- for key in header_map.keys() {
- let entries = header_map
- .get_all(key)
- .iter()
- .filter_map(|value| Some(value.to_str().ok()?.to_string()))
- .collect();
-
- join_headers.insert(key.to_string(), entries);
- }
-
- let out = Self {
- join_headers,
- connect_opts,
- url,
- format: format.to_string(),
- csrf_token,
- phx_id,
- phx_static,
- phx_session,
- dead_render,
- style_urls,
- has_live_reload,
- cookies,
- };
-
- debug!("Session data successfully acquired");
- debug!("{out:?}");
-
- Ok(out)
- }
-
- /// reconstruct the live socket url from the session data
- pub fn get_live_socket_url(&self) -> Result {
- let websocket_scheme = match self.url.scheme() {
- "https" => "wss",
- "http" => "ws",
- scheme => {
- return Err(LiveSocketError::SchemeNotSupported {
- scheme: scheme.to_string(),
- })
- }
- };
-
- let port = self.url.port().map(|p| format!(":{p}")).unwrap_or_default();
- let host = self.url.host_str().ok_or(LiveSocketError::NoHostInURL)?;
-
- let mut websocket_url = Url::parse(&format!("{websocket_scheme}://{host}{port}"))?;
-
- websocket_url
- .query_pairs_mut()
- .append_pair(LVN_VSN_KEY, LVN_VSN)
- .append_pair(CSRF_KEY, &self.csrf_token)
- .append_pair(MOUNT_KEY, "0")
- .append_pair(FMT_KEY, &self.format);
-
- websocket_url.set_path("/live/websocket");
-
- debug!("websocket url: {websocket_url}");
-
- Ok(websocket_url)
- }
-
- pub fn create_join_payload(
- &self,
- additional_params: &Option>,
- redirect: Option,
- ) -> Payload {
- let mut params = HashMap::new();
- params.insert(MOUNT_KEY.to_string(), serde_json::json!(0));
- params.insert(CSRF_KEY.to_string(), serde_json::json!(self.csrf_token));
- params.insert(FMT_KEY.to_string(), serde_json::json!(self.format));
- if let Some(join_params) = additional_params {
- params.extend(
- join_params
- .iter()
- .map(|(k, v)| (k.clone(), v.clone().into())),
- );
- }
-
- let payload = JoinRequestPayload {
- static_token: self.phx_static.clone(),
- session: self.phx_session.clone(),
- url_or_redirect: redirect
- .map(|r| UrlOrRedirect::Redirect { redirect: r })
- .unwrap_or_else(|| UrlOrRedirect::Url {
- url: self.url.to_string(),
- }),
- params,
- };
-
- let json = serde_json::to_value(payload).expect("Serde Error");
- Payload::JSONPayload { json: json.into() }
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct LiveSocket {
- pub socket: Mutex>,
- pub session_data: Mutex,
- pub(super) navigation_ctx: Mutex,
-}
-
-// non uniffi bindings.
-impl LiveSocket {
- /// Gets the 'dead render', a static html page containing metadata about how to
- /// connect to a websocket and initialize the live view session.
- async fn get_dead_render(
- url: &Url,
- format: &str,
- options: &ConnectOpts,
- client: Client,
- ) -> Result<(Document, Vec, Url, HeaderMap), LiveSocketError> {
- let ConnectOpts {
- headers,
- body,
- method,
- timeout_ms,
- } = options;
-
- let method = method.clone().unwrap_or(Method::Get).into();
-
- // TODO: Check if params contains all of phx_id, phx_static, phx_session and csrf_token, if
- // it does maybe we don't need to do a full dead render.
- let mut url = url.clone();
- if url.query_pairs().all(|(name, _)| name != FMT_KEY) {
- url.query_pairs_mut().append_pair(FMT_KEY, format);
- }
-
- let headers = (&headers.clone().unwrap_or_default())
- .try_into()
- .map_err(|e| LiveSocketError::InvalidHeader {
- error: format!("{e:?}"),
- })?;
-
- let req = reqwest::Request::new(method, url.clone());
- let builder = reqwest::RequestBuilder::from_parts(client, req);
-
- let builder = if let Some(body) = body {
- builder.body(body.clone())
- } else {
- builder
- };
-
- let timeout = Duration::from_millis(*timeout_ms);
- let (client, request) = builder.timeout(timeout).headers(headers).build_split();
-
- let mut resp = client.execute(request?).await?;
- let mut headers = resp.headers().clone();
-
- for _ in 0..MAX_REDIRECTS {
- if !resp.status().is_redirection() {
- log::debug!("{resp:?}");
- break;
- }
- log::debug!("-- REDIRECTING -- ");
- log::debug!("{resp:?}");
-
- let mut location = resp
- .headers()
- .get(LOCATION)
- .and_then(|loc| str::from_utf8(loc.as_bytes()).ok())
- .and_then(|loc| url.join(loc).ok())
- .ok_or_else(|| LiveSocketError::Request {
- error: "No valid redirect location in 300 response".into(),
- })?;
-
- if location.query_pairs().all(|(name, _)| name != FMT_KEY) {
- location.query_pairs_mut().append_pair(FMT_KEY, format);
- }
-
- resp = client.get(location).send().await?;
-
- // TODO: Remove this when persistent state is managed by core
- let cookies = resp.headers().get_all(SET_COOKIE);
-
- for cookie in cookies {
- if headers.try_append(SET_COOKIE, cookie.clone()).is_err() {
- log::error!("Could not collect set cookie headers");
- }
- }
- }
-
- let status = resp.status();
-
- #[cfg(not(test))]
- let jar = COOKIE_JAR.get_or_init(|| Jar::default().into());
-
- #[cfg(test)]
- let jar = TEST_COOKIE_JAR.with(|inner| inner.clone());
-
- let cookies = jar
- .cookies(&url)
- .as_ref()
- .and_then(|cookie_text| cookie_text.to_str().ok())
- .map(|text| {
- text.split(";")
- .map(str::trim)
- .map(String::from)
- .collect::>()
- })
- .unwrap_or_default();
-
- let url = resp.url().clone();
- let resp_text = resp.text().await?;
-
- if !status.is_success() {
- return Err(LiveSocketError::ConnectionError(resp_text));
- }
-
- let dead_render = Document::parse(&resp_text)?;
- trace!("document:\n{dead_render}\n\n\n");
- Ok((dead_render, cookies, url, headers))
- }
-}
-/// Stores a cookie for the duration of the application run.
-#[uniffi::export]
-pub fn store_session_cookie(cookie: String, url: String) -> Result<(), LiveSocketError> {
- let url = Url::parse(&url)?;
-
- #[cfg(not(test))]
- let jar = COOKIE_JAR.get_or_init(|| Jar::default().into());
-
- #[cfg(test)]
- let jar = TEST_COOKIE_JAR.with(|inner| inner.clone());
-
- jar.add_cookie_str(&cookie, &url);
-
- Ok(())
-}
-
-#[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))]
-impl LiveSocket {
- // This is just for the jetpack client. This is an associated function constructor.
- #[uniffi::constructor]
- pub async fn connect(
- url: String,
- format: String,
- options: Option,
- ) -> Result {
- Self::new(url, format, options).await
- }
-
- #[uniffi::constructor]
- pub async fn new(
- url: String,
- format: String,
- options: Option,
- ) -> Result {
- let url = Url::parse(&url)?;
- let options = options.unwrap_or_default();
-
- #[cfg(not(test))]
- let jar = COOKIE_JAR.get_or_init(|| Jar::default().into());
-
- #[cfg(test)]
- let jar = TEST_COOKIE_JAR.with(|inner| inner.clone());
-
- let client = reqwest::Client::builder()
- .cookie_provider(jar.clone())
- .redirect(Policy::none())
- .build()?;
-
- // Make HTTP request to get initial dead render, an HTML document with
- // metadata needed to set up the liveview websocket connection.
- let session_data = SessionData::request(&url, &format, options, client).await?;
- let websocket_url = session_data.get_live_socket_url()?;
-
- let socket = Socket::spawn(websocket_url, Some(session_data.cookies.clone()))
- .await?
- .into();
-
- let navigation_ctx = Mutex::new(NavCtx::default());
-
- navigation_ctx.lock().expect("Lock Poisoned!").navigate(
- url.clone(),
- NavOptions::default(),
- false,
- );
-
- Ok(Self {
- socket,
- session_data: session_data.into(),
- navigation_ctx,
- })
- }
-
- /// Returns the url of the final dead render
- pub fn join_url(&self) -> String {
- lock!(self.session_data).url.to_string().clone()
- }
-
- /// Returns the headers of the final dead render response
- pub fn join_headers(&self) -> HashMap> {
- lock!(self.session_data).join_headers.clone()
- }
-
- pub fn csrf_token(&self) -> String {
- lock!(self.session_data).csrf_token.clone()
- }
-
- pub fn cookies(&self) -> Vec {
- lock!(self.session_data).cookies.clone()
- }
-
- pub fn dead_render(&self) -> FFiDocument {
- lock!(self.session_data).dead_render.clone().into()
- }
-
- pub fn style_urls(&self) -> Vec {
- self.session_data
- .lock()
- .expect("lock poisoined")
- .style_urls
- .clone()
- }
-
- pub async fn join_livereload_channel(&self) -> Result {
- let mut url = lock!(self.session_data).url.clone();
-
- let websocket_scheme = match url.scheme() {
- "https" => "wss",
- "http" => "ws",
- scheme => {
- return Err(LiveSocketError::SchemeNotSupported {
- scheme: scheme.to_string(),
- })
- }
- };
- let _ = url.set_scheme(websocket_scheme);
- url.set_path("phoenix/live_reload/socket/websocket");
- url.query_pairs_mut().append_pair(LVN_VSN_KEY, LVN_VSN);
-
- let cookies = lock!(self.session_data).cookies.clone();
-
- let socket = Socket::spawn(url.clone(), Some(cookies)).await?;
- socket.connect(self.timeout()).await?;
-
- debug!("Joining live reload channel on url {url}");
- let channel = socket
- .channel(Topic::from_string("phoenix:live_reload".to_string()), None)
- .await?;
- debug!("Created channel for live reload socket");
- let join_payload = channel.join(self.timeout()).await?;
- let document = Document::empty();
-
- Ok(LiveChannel {
- channel,
- join_params: Default::default(),
- join_payload,
- socket,
- document: document.into(),
- timeout: self.timeout(),
- })
- }
-
- pub async fn join_liveview_channel(
- &self,
- join_params: Option>,
- redirect: Option,
- ) -> Result {
- self.socket().connect(self.timeout()).await?;
-
- let session_data = lock!(self.session_data).clone();
-
- let join_payload = session_data.create_join_payload(&join_params, redirect);
-
- let channel = self
- .socket()
- .channel(
- Topic::from_string(format!("lv:{}", session_data.phx_id)),
- Some(join_payload),
- )
- .await?;
-
- let join_payload = channel.join(self.timeout()).await?;
-
- trace!("Join payload: {join_payload:#?}");
- let document = match join_payload {
- Payload::JSONPayload {
- json: JSON::Object { ref object },
- } => {
- if let Some(rendered) = object.get("rendered") {
- let rendered = rendered.to_string();
- let root: RootDiff = serde_json::from_str(rendered.as_str())?;
- trace!("root diff: {root:#?}");
- let root: Root = root.try_into()?;
- let rendered: String = root.clone().try_into()?;
- let mut document = Document::parse(&rendered)?;
- document.fragment_template = Some(root);
- Some(document)
- } else {
- None
- }
- }
- _ => None,
- }
- .ok_or(LiveSocketError::NoDocumentInJoinPayload)?;
-
- Ok(LiveChannel {
- channel,
- join_payload,
- join_params: join_params.unwrap_or_default(),
- socket: self.socket(),
- document: document.into(),
- timeout: self.timeout(),
- })
- }
-
- /// Returns the connection timeout duration for each connection attempt
- pub fn timeout(&self) -> Duration {
- Duration::from_millis(lock!(self.session_data).connect_opts.timeout_ms)
- }
-
- /// Returns the socket status
- pub fn status(&self) -> SocketStatus {
- self.socket().status()
- }
-
- pub fn socket(&self) -> Arc {
- lock!(self.socket).clone()
- }
-
- pub fn has_live_reload(&self) -> bool {
- lock!(self.session_data).has_live_reload
- }
-}
diff --git a/crates/core/src/live_socket/tests/error.rs b/crates/core/src/live_socket/tests/error.rs
deleted file mode 100644
index 3bcb7ae2..00000000
--- a/crates/core/src/live_socket/tests/error.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-use super::*;
-use crate::error::*;
-
-#[tokio::test]
-async fn dead_render_error() {
- let _ = env_logger::builder()
- .parse_default_env()
- .is_test(true)
- .try_init();
-
- let url = format!("http://{HOST}/doesnt-exist");
- let live_socket_err =
- LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()).await;
- assert!(live_socket_err.is_err());
- let live_socket_err = live_socket_err.err().unwrap();
- assert!(matches!(
- live_socket_err,
- LiveSocketError::ConnectionError { .. }
- ));
- log::debug!("ERROR HTML: {live_socket_err}");
-}
diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs
deleted file mode 100644
index 1c8df0b8..00000000
--- a/crates/core/src/live_socket/tests/mod.rs
+++ /dev/null
@@ -1,213 +0,0 @@
-use std::{sync::Arc, time::Duration};
-
-use super::*;
-use crate::{
- callbacks::*,
- dom::{NodeData, NodeRef},
-};
-mod error;
-mod navigation;
-mod streaming;
-mod upload;
-
-#[cfg(target_os = "android")]
-const HOST: &str = "10.0.2.2:4001";
-
-#[cfg(not(target_os = "android"))]
-const HOST: &str = "127.0.0.1:4001";
-
-use phoenix_channels_client::ChannelStatus;
-use pretty_assertions::assert_eq;
-
-macro_rules! assert_doc_eq {
- ($gold:expr, $test:expr) => {{
- use crate::dom::Document;
- let gold = Document::parse($gold).expect("Gold document failed to parse");
- let test = Document::parse($test).expect("Test document failed to parse");
- assert_eq!(gold.to_string(), test.to_string());
- }};
-}
-
-pub(crate) use assert_doc_eq;
-use tokio::sync::mpsc::*;
-
-struct Inspector {
- tx: UnboundedSender<(ChangeType, NodeData)>,
- doc: crate::dom::ffi::Document,
-}
-
-impl Inspector {
- pub fn new(
- doc: crate::dom::ffi::Document,
- ) -> (Self, UnboundedReceiver<(ChangeType, NodeData)>) {
- let (tx, rx) = unbounded_channel();
- let out = Self { tx, doc };
- (out, rx)
- }
-}
-
-/// An extremely simple change handler that reports diffs in order
-/// over an unbounded channel
-impl DocumentChangeHandler for Inspector {
- fn handle_document_change(
- &self,
- change_type: ChangeType,
- _node_ref: Arc,
- node_data: NodeData,
- _parent: Option>,
- ) {
- let doc = self.doc.inner();
-
- let _test = doc
- .try_lock()
- .expect("document was locked during change handler!");
-
- self.tx
- .send((change_type, node_data))
- .expect("Message Never Received.");
- }
-}
-
-#[tokio::test]
-async fn channels_drop_on_shutdown() {
- let _ = env_logger::builder()
- .parse_default_env()
- .is_test(true)
- .try_init();
-
- let url = format!("http://{HOST}/hello");
-
- let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default())
- .await
- .expect("Failed to get liveview socket");
-
- let live_channel = live_socket
- .join_liveview_channel(None, None)
- .await
- .expect("Failed to join channel");
- let chan_clone = live_channel.channel().clone();
- let handle = tokio::spawn(async move {
- live_channel
- .merge_diffs()
- .await
- .expect("Failed to merge diffs");
- });
-
- live_socket
- .socket()
- .shutdown()
- .await
- .expect("shutdown error");
-
- assert!(handle.is_finished());
- assert_eq!(chan_clone.status(), ChannelStatus::ShutDown);
-}
-
-#[tokio::test]
-async fn join_redirect() {
- let _ = env_logger::builder()
- .parse_default_env()
- .is_test(true)
- .try_init();
-
- let url = format!("http://{HOST}/redirect_from");
-
- let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default())
- .await
- .expect("Failed to get liveview socket");
-
- let live_channel = live_socket
- .join_liveview_channel(None, None)
- .await
- .expect("Failed to join channel");
-
- let join_doc = live_channel
- .join_document()
- .expect("Failed to render join payload");
-
- let expected = r#"
-
-
-
- Redirected!
-
-"#;
- assert_doc_eq!(expected, join_doc.to_string());
-
- let _live_channel = live_socket
- .join_livereload_channel()
- .await
- .expect("Failed to join channel");
-}
-
-#[tokio::test]
-async fn join_live_view() {
- let _ = env_logger::builder()
- .parse_default_env()
- .is_test(true)
- .try_init();
-
- let url = format!("http://{HOST}/hello");
- let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default())
- .await
- .expect("Failed to get liveview socket");
-
- let style_urls = live_socket.style_urls();
- let expected_style_urls = vec!["/assets/app.swiftui.styles".to_string()];
- assert_eq!(style_urls, expected_style_urls);
-
- let live_channel = live_socket
- .join_liveview_channel(None, None)
- .await
- .expect("Failed to join channel");
-
- let join_doc = live_channel
- .join_document()
- .expect("Failed to render join payload");
- let rendered = format!("{}", join_doc);
- let expected = r#"
-
-
-
- Hello SwiftUI!
-
-"#;
- assert_doc_eq!(expected, rendered);
-
- let _live_channel = live_socket
- .join_livereload_channel()
- .await
- .expect("Failed to join channel");
-}
-
-#[tokio::test]
-async fn channel_redirect() {
- let _ = env_logger::builder()
- .parse_default_env()
- .is_test(true)
- .try_init();
-
- let url = format!("http://{HOST}/hello");
- let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default())
- .await
- .expect("Failed to get liveview socket");
-
- let live_channel = live_socket
- .join_liveview_channel(None, None)
- .await
- .expect("Failed to join channel");
-
- //live_channel.channel().shutdown().await.expect("Failed to leave live channel");
- //
- // Leave should be: ["4","13","lv:phx-F_azBZxXhBqPjAAm","phx_leave",{}]
- live_channel
- .channel()
- .leave()
- .await
- .expect("Failed to leave live channel");
- let redirect = format!("http://{HOST}/upload");
- let _live_channel = live_socket
- .join_liveview_channel(None, Some(redirect))
- .await
- .expect("Failed to join channel");
-}
diff --git a/crates/core/src/live_socket/tests/navigation.rs b/crates/core/src/live_socket/tests/navigation.rs
deleted file mode 100644
index f0b759d7..00000000
--- a/crates/core/src/live_socket/tests/navigation.rs
+++ /dev/null
@@ -1,308 +0,0 @@
-use std::sync::{Arc, Mutex};
-
-use pretty_assertions::assert_eq;
-use reqwest::Url;
-use serde::{Deserialize, Serialize};
-
-use super::assert_doc_eq;
-use crate::{
- callbacks::*,
- live_socket::{
- navigation::{NavCtx, NavOptions},
- LiveSocket,
- },
-};
-
-// Mock event handler used to validate the internal
-// navigation objects state.
-pub struct NavigationInspector {
- last_event: Mutex