diff --git a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift index ab7632123..1cb3b2b11 100644 --- a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift +++ b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift @@ -44,10 +44,6 @@ public class LiveSessionCoordinator: ObservableObject { @Published private(set) var rootLayout: LiveViewNativeCore.Document? @Published private(set) var stylesheet: Stylesheet? - // Socket connection - var liveSocket: LiveViewNativeCore.LiveSocket? - var socket: LiveViewNativeCore.Socket? - private var liveReloadChannel: LiveViewNativeCore.LiveChannel? private var liveReloadListener: Channel.EventStream? private var liveReloadListenerLoop: Task<(), any Error>? @@ -60,6 +56,17 @@ public class LiveSessionCoordinator: ObservableObject { private var reconnectAttempts = 0 + // TODO: Once this works sub out the rest + private var persistence: SimplePersistentStore + private var eventHandler: SimpleEventHandler + private var patchHandler: SimplePatchHandler + private var navHandler: SimpleNavHandler + + private var liveviewClient: LiveViewClient? + private var builder: LiveViewClientBuilder + + + /// Positions for `` elements with an explicit ID. /// /// These positions are used for scroll restoration on back navigation. @@ -80,7 +87,15 @@ public class LiveSessionCoordinator: ObservableObject { public convenience init(_ host: some LiveViewHost, config: LiveSessionConfiguration = .init(), customRegistryType: R.Type = R.self) { self.init(host.url, config: config, customRegistryType: customRegistryType) } - + + public func clientChannel() -> LiveViewClientChannel? { + self.liveviewClient?.channel() + } + + public func status() -> SocketStatus { + (try? self.liveviewClient?.status()) ?? .disconnected + } + /// Creates a new coordinator with a custom registry. /// - Parameter url: The URL of the page to establish the connection to. /// - Parameter config: The configuration for this coordinator. @@ -88,12 +103,22 @@ public class LiveSessionCoordinator: ObservableObject { public init(_ url: URL, config: LiveSessionConfiguration = .init(), customRegistryType _: R.Type = R.self) { self.url = url.appending(path: "").absoluteURL + + self.patchHandler = SimplePatchHandler() + self.eventHandler = SimpleEventHandler() + self.navHandler = SimpleNavHandler() + self.persistence = SimplePersistentStore() + + self.builder = LiveViewClientBuilder(); + + self.builder.setPatchHandler(patchHandler) + self.builder.setNavigationHandler(navHandler) + self.builder.setPersistenceProvider(persistence) + self.builder.setLiveChannelEventHandler(eventHandler) + self.builder.setLogLevel(.debug) + self.configuration = config - // load cookies into core - for cookie in HTTPCookieStorage.shared.cookies(for: url) ?? [] { - try? LiveViewNativeCore.storeSessionCookie("\(cookie.name)=\(cookie.value)", self.url.absoluteString) - } self.navigationPath = [.init(url: url, coordinator: .init(session: self, url: self.url), navigationTransition: nil, pendingView: nil)] @@ -103,26 +128,40 @@ public class LiveSessionCoordinator: ObservableObject { .sink(receiveValue: { [weak self] value in self?.eventSubject.send(value) }) + + self.eventHandler.viewReloadSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] newView in + guard let self else { return } + guard let last = self.navigationPath.last else { return } + if let client = self.liveviewClient { + last.coordinator.join(client, self.eventHandler, self.patchHandler) + } + }.store(in: &cancellables) $navigationPath.scan(([LiveNavigationEntry](), [LiveNavigationEntry]()), { ($0.1, $1) }).sink { [weak self] prev, next in guard let self else { return } + guard let client = liveviewClient else { return } Task { - try await prev.last?.coordinator.disconnect() + prev.last?.coordinator.disconnect() if prev.count > next.count { // back navigation (we could be going back multiple pages at once, so use `traverseTo` instead of `back`) - let targetEntry = self.liveSocket!.getEntries()[next.count - 1] - next.last?.coordinator.join( - try await self.liveSocket!.traverseTo(targetEntry.id, next.last!.coordinator.liveChannel, nil) - ) + var opts = NavActionOptions() + opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]) + let targetEntry = client.getEntries()[next.count - 1] + let _ = try await client.traverseTo(targetEntry.id, opts) } else if next.count > prev.count && prev.count > 0 { // forward navigation (from `redirect` or ``) - next.last?.coordinator.join( - try await self.liveSocket!.navigate(next.last!.url.absoluteString, next.last!.coordinator.liveChannel, NavOptions(action: .push)) - ) + var opts = NavOptions() + opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]) + opts.action = .push + let _ = try await client.navigate(next.last!.url.absoluteString, opts) } else if next.count == prev.count { - guard let liveChannel = try await self.liveSocket?.navigate(next.last!.url.absoluteString, next.last!.coordinator.liveChannel, NavOptions(action: .replace)) - else { return } - next.last?.coordinator.join(liveChannel) + // TODO: this will fire on a patch event! this should not fire on a patch event + var opts = NavOptions() + opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]) + opts.action = .replace + let _ = try await client.navigate(next.last!.url.absoluteString, opts) } } }.store(in: &cancellables) @@ -182,31 +221,25 @@ public class LiveSessionCoordinator: ObservableObject { let headers = (configuration.headers ?? [:]) .merging(additionalHeaders ?? [:]) { $1 } - - self.liveSocket = try await LiveSocket( - originalURL.absoluteString, - LiveSessionParameters.platform, - ConnectOpts( - headers: headers, - body: httpBody.flatMap({ String(data: $0, encoding: .utf8) }), - method: httpMethod.flatMap(Method.init(_:)), - timeoutMs: 10_000 - ) + + let opts = ClientConnectOpts( + joinParams: .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]), + headers: .some(headers), + method: Method.init(httpMethod ?? "Get"), + requestBody: httpBody ) - // save cookies to storage - HTTPCookieStorage.shared.setCookies( - (self.liveSocket!.joinHeaders()["set-cookie"] ?? []).flatMap { - HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": $0], for: URL(string: self.liveSocket!.joinUrl())!) - }, - for: self.url, - mainDocumentURL: nil - ) + if let client = self.liveviewClient { + try await client.reconnect(originalURL.absoluteString, opts) + } else { + self.liveviewClient = try await self.builder.connect(originalURL.absoluteString, opts) + self.navigationPath.last!.coordinator.join(self.liveviewClient!, self.eventHandler, self.patchHandler) + } + - self.socket = self.liveSocket?.socket() - self.rootLayout = self.liveSocket!.deadRender() - let styleURLs = self.liveSocket!.styleUrls() + self.rootLayout = try self.liveviewClient!.deadRender() + let styleURLs = try self.liveviewClient!.styleUrls() self.stylesheet = try await withThrowingTaskGroup(of: Stylesheet.self) { @Sendable group in for style in styleURLs { @@ -229,49 +262,21 @@ public class LiveSessionCoordinator: ObservableObject { } } - let liveChannel = try await self.liveSocket!.joinLiveviewChannel( - .some([ - "_format": .str(string: LiveSessionParameters.platform), - "_interface": .object(object: LiveSessionParameters.platformParams) - ]), - nil - ) - self.navigationPath.last!.coordinator.join(liveChannel) self.state = .connected - if self.liveSocket!.hasLiveReload() { - self.liveReloadChannel = try await self.liveSocket!.joinLivereloadChannel() - bindLiveReloadListener() - } } catch { self.state = .connectionFailed(error) } } - func bindLiveReloadListener() { - let eventListener = self.liveReloadChannel!.channel().eventStream() - self.liveReloadListener = eventListener - self.liveReloadListenerLoop = Task { @MainActor [weak self] in - for try await event in eventListener { - guard let self else { return } - switch event.event { - case .user(user: "assets_change"): - try await self.disconnect() - self.navigationPath = [.init(url: self.url, coordinator: .init(session: self, url: self.url), navigationTransition: nil, pendingView: nil)] - try await self.connect() - default: - continue - } - } - } - } + private func disconnect(preserveNavigationPath: Bool = false) async { do { for entry in self.navigationPath { - try await entry.coordinator.disconnect() + entry.coordinator.disconnect() if !preserveNavigationPath { entry.coordinator.document = nil } @@ -284,11 +289,10 @@ public class LiveSessionCoordinator: ObservableObject { self.navigationPath = [self.navigationPath.first!] } - try await self.liveReloadChannel?.channel().leave() - self.liveReloadChannel = nil - try await self.socket?.disconnect() - self.socket = nil - self.liveSocket = nil + + if let client = self.liveviewClient { + try await client.disconnect() + } self.state = .disconnected } catch { self.state = .connectionFailed(error) @@ -306,23 +310,8 @@ public class LiveSessionCoordinator: ObservableObject { self.url = url self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil, pendingView: nil)] } - try await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers) -// do { -// if let url { -// try await self.disconnect(preserveNavigationPath: false) -// self.url = url -// self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil, pendingView: nil)] -// } else { -// // preserve the navigation path, but still clear the stale documents, since they're being completely replaced. -// try await self.disconnect(preserveNavigationPath: true) -// for entry in self.navigationPath { -// entry.coordinator.document = nil -// } -// } -// try await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers) -// } catch { -// self.state = .connectionFailed(error) -// } + await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers) + } /// Creates a publisher that can be used to listen for server-sent LiveView events. @@ -353,6 +342,22 @@ public class LiveSessionCoordinator: ObservableObject { .store(in: &eventHandlers) } + public func postFormData( + url: Url, + formData: [String: String] + ) async throws { + + + + if let client = self.liveviewClient { + + try await client.postForm(url.absoluteString, + formData, + .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]), + nil) + } + } + func redirect( _ redirect: LiveRedirect, navigationTransition: Any? = nil, @@ -536,3 +541,5 @@ fileprivate extension URL { extension Socket: @unchecked Sendable {} extension Channel: @unchecked Sendable {} +extension LiveViewClient: @unchecked Sendable {} +extension LiveViewClientBuilder: @unchecked Sendable {} diff --git a/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift b/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift index 6bc59ca9e..8908a90b3 100644 --- a/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift +++ b/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift @@ -33,10 +33,10 @@ public class LiveViewCoordinator: ObservableObject { @_spi(LiveForm) public private(set) weak var session: LiveSessionCoordinator! - var url: URL + private var liveviewClient: LiveViewClient? + private var channel: LiveViewClientChannel? - private(set) var liveChannel: LiveViewNativeCore.LiveChannel? - private var channel: LiveViewNativeCore.Channel? + var url: URL @Published var document: LiveViewNativeCore.Document? { didSet { @@ -44,6 +44,7 @@ public class LiveViewCoordinator: ObservableObject { uploadRef = 0 } } + private var elementChangedSubjects = [NodeRef:ObjectWillChangePublisher]() func elementChanged(_ ref: NodeRef) -> ObjectWillChangePublisher { guard let subject = elementChangedSubjects[ref] else { @@ -58,10 +59,7 @@ public class LiveViewCoordinator: ObservableObject { private(set) internal var eventSubject = PassthroughSubject<(String, Payload), Never>() private(set) internal var eventHandlers = Set() -// private var eventListener: Channel.EventStream? - private var eventListenerLoop: Task<(), any Error>? -// private var statusListener: Channel.StatusStream? - private var statusListenerLoop: Task<(), any Error>? + private(set) internal var liveViewModel = LiveViewModel() @@ -77,10 +75,6 @@ public class LiveViewCoordinator: ObservableObject { self.url = url } - deinit { - self.eventListenerLoop?.cancel() - self.statusListenerLoop?.cancel() - } /// Pushes a LiveView event with the given name and payload to the server. /// @@ -114,17 +108,37 @@ public class LiveViewCoordinator: ObservableObject { @discardableResult internal func doPushEvent(_ event: String, payload: LiveViewNativeCore.Payload) async throws -> [String:Any]? { - guard let channel = channel else { - return nil + + guard case .connected = state else { + throw LiveSocketError.DisconnectionError } - guard case .joined = channel.status() else { + if let replyPayload = try await channel?.call(event, payload) { + return try await handleEventReplyPayload(replyPayload) + } else { + return nil + } + } + + @discardableResult + public func call(event: String, payload: LiveViewNativeCore.Payload) async throws -> LiveViewNativeCore.Payload? { + guard case .connected = state else { throw LiveSocketError.DisconnectionError } - let replyPayload = try await channel.call(event: .user(user: event), payload: payload, timeout: PUSH_TIMEOUT) + if let replyPayload = try await channel?.call(event, payload) { + return replyPayload + } else { + return nil + } + } + + public func uploadFile(file: LiveViewNativeCore.LiveFile) async throws { + guard case .connected = state else { + throw LiveSocketError.DisconnectionError + } - return try await handleEventReplyPayload(replyPayload) + try await liveviewClient?.uploadFiles([file]); } /// Creates a publisher that can be used to listen for server-sent LiveView events. @@ -186,10 +200,7 @@ public class LiveViewCoordinator: ObservableObject { .store(in: &eventHandlers) } - private func handleDiff(payload: LiveViewNativeCore.Json, baseURL: URL) throws { - handleEvents(payload) - try self.document?.mergeFragmentJson(String(data: try JSONEncoder().encode(payload), encoding: .utf8)!) - } + func handleEventReplyPayload(_ replyPayload: LiveViewNativeCore.Payload) async throws -> [String:Any]? { switch replyPayload { @@ -197,22 +208,11 @@ public class LiveViewCoordinator: ObservableObject { switch json { case let .object(object): if case let .object(diff) = object["diff"] { - try self.handleDiff(payload: .object(object: diff), baseURL: self.url) if case let .object(reply) = diff["r"] { return reply } - } else if case let .object(redirectObject) = object["live_redirect"], - let redirect = LiveRedirect(from: redirectObject, relativeTo: self.url) - { - try await session.redirect(redirect) - } else if case let .object(redirectObject) = object["redirect"], - case let .str(destinationString) = redirectObject["to"], - let destination = URL(string: destinationString, relativeTo: self.url) - { - try await session.redirect(.init(kind: .push, to: destination, mode: .replaceTop)) - } else { - return nil } + return nil default: logger.error("unhandled event reply: \(String(reflecting: replyPayload))") } @@ -237,98 +237,57 @@ public class LiveViewCoordinator: ObservableObject { } } - func bindEventListener() { - self.eventListenerLoop = Task { [weak self, weak channel] in - guard let channel else { return } - let eventListener = channel.eventStream() - for try await event in eventListener { - guard let self else { return } - guard !Task.isCancelled else { return } - do { - switch event.event { - case .user(user: "diff"): - switch event.payload { - case let .jsonPayload(json): - try self.handleDiff(payload: json, baseURL: self.url) - case .binary: - fatalError() - } - case .user(user: "live_redirect"): - guard case let .jsonPayload(json) = event.payload, - case let .object(payload) = json, - let redirect = LiveRedirect(from: payload, relativeTo: self.url) - else { break } - try await self.session.redirect(redirect) - case .user(user: "live_patch"): - guard case let .jsonPayload(json) = event.payload, - case let .object(payload) = json, - let redirect = LiveRedirect(from: payload, relativeTo: self.url, mode: .patch) - else { return } - try await self.session.redirect(redirect) - case .user(user: "redirect"): - guard case let .jsonPayload(json) = event.payload, - case let .object(payload) = json, - let destination = (payload["to"] as? String).flatMap({ URL.init(string: $0, relativeTo: self.url) }) - else { return } - try await self.session.redirect(.init(kind: .push, to: destination, mode: .replaceTop)) - default: - logger.error("Unhandled event: \(String(describing: event))") - } - } catch { - logger.error("Event handling error: \(error.localizedDescription)") - } + + + func join(_ client: LiveViewNativeCore.LiveViewClient, + _ eventListener: SimpleEventHandler, + _ docHandler: SimplePatchHandler + ) { + self.liveviewClient = client + self.channel = client.channel() + self.document = try! client.document() + + eventListener.channelStatusSubject + .receive(on: DispatchQueue.main) + .sink { event in + self.internalState = switch event.status { + case .joined: + .connected + case .joining, .waitingForSocketToConnect, .waitingToJoin: + .connecting + case .waitingToRejoin: + .reconnecting + case .leaving, .left, .shuttingDown, .shutDown: + .disconnected } - } - } - - func bindDocumentListener() { - self.document?.on(.changed) { [weak self] nodeRef, nodeData, parent in - guard let self else { return } - switch nodeData { + }.store(in: &eventHandlers) + + + docHandler.patchEventSubject + .receive(on: DispatchQueue.main) + .sink { event in + switch event.data { case .root: // when the root changes, update the `NavStackEntry` itself. self.objectWillChange.send() case .leaf: // text nodes don't have their own views, changes to them need to be handled by the parent Text view - if let parent { - self.elementChanged(nodeRef).send() + // note: aren't these branches the same? + if event.parent != nil { + self.elementChanged(event.node).send() } else { - self.elementChanged(nodeRef).send() + self.elementChanged(event.node).send() } case .nodeElement: // when a single element changes, send an update only to that element. - self.elementChanged(nodeRef).send() + self.elementChanged(event.node).send() } - } - } + }.store(in: &eventHandlers) - func join(_ liveChannel: LiveViewNativeCore.LiveChannel) { - self.liveChannel = liveChannel - let channel = liveChannel.channel() - self.channel = channel - - statusListenerLoop = Task { @MainActor [weak self, unowned channel] in - let statusListener = channel.statusStream() - for try await status in statusListener { - self?.internalState = switch status { - case .joined: - .connected - case .joining, .waitingForSocketToConnect, .waitingToJoin: - .connecting - case .waitingToRejoin: - .reconnecting - case .leaving, .left, .shuttingDown, .shutDown: - .disconnected - } - } - } - - self.bindEventListener() - self.document = liveChannel.document() - self.bindDocumentListener() + - switch liveChannel.joinPayload() { + switch try! client.joinPayload() { case let .jsonPayload(.object(payload)): self.handleEvents(payload["rendered"]!) default: @@ -338,13 +297,9 @@ public class LiveViewCoordinator: ObservableObject { self.internalState = .connected } - func disconnect() async throws { - try await self.channel?.leave() - self.eventListenerLoop = nil - self.statusListenerLoop = nil - self.liveChannel = nil + func disconnect() { + self.liveviewClient = nil self.channel = nil - self.internalState = .setup } } diff --git a/Sources/LiveViewNative/Live/LiveView.swift b/Sources/LiveViewNative/Live/LiveView.swift index 7018e242a..e9a2cd738 100644 --- a/Sources/LiveViewNative/Live/LiveView.swift +++ b/Sources/LiveViewNative/Live/LiveView.swift @@ -237,7 +237,7 @@ public struct LiveView< .onChange(of: scenePhase) { newValue in guard case .active = newValue else { return } - if case .connected = session.socket?.status() { + if case .connected = session.status() { return } Task { diff --git a/Sources/LiveViewNative/ViewModel.swift b/Sources/LiveViewNative/ViewModel.swift index 3c77307d0..119c850e6 100644 --- a/Sources/LiveViewNative/ViewModel.swift +++ b/Sources/LiveViewNative/ViewModel.swift @@ -164,14 +164,7 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible { } public func buildFormURLComponents() throws -> URLComponents { - let data = try data.mapValues { value in - if let value = value as? String { - return value - } else { - return try value.formQueryEncoded() - } - } - + let data = try toDictionary() var components = URLComponents() components.queryItems = data.map { URLQueryItem(name: $0.key, value: $0.value) @@ -179,6 +172,16 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible { return components } + + public func toDictionary() throws -> [String: String] { + return try data.mapValues { value in + if let value = value as? String { + return value + } else { + return try value.formQueryEncoded() + } + } + } @MainActor private func pushFormEvent(_ event: String) async throws { @@ -269,8 +272,6 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible { fileName: String, coordinator: LiveViewCoordinator ) async throws { - guard let liveChannel = coordinator.liveChannel - else { return } let file = LiveFile( contents, @@ -280,8 +281,8 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible { id ) if let changeEventName { - let replyPayload = try await coordinator.liveChannel!.channel().call( - event: .user(user: "event"), + let replyPayload = try await coordinator.call( + event: "event", payload: .jsonPayload(json: .object(object: [ "type": .str(string: "form"), "event": .str(string: changeEventName), @@ -299,15 +300,16 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible { ]) ]) ]) - ])), - timeout: 10_000 - ) - try await coordinator.handleEventReplyPayload(replyPayload) + ])) + ); + if let payload = replyPayload { + try await coordinator.handleEventReplyPayload(payload) + } } self.fileUploads.append(.init( id: id, data: contents, - upload: { try await liveChannel.uploadFile(file) } + upload: { try await coordinator.uploadFile(file: file) } )) } } diff --git a/priv/templates/lvn.swiftui.gen/core_components.ex b/priv/templates/lvn.swiftui.gen/core_components.ex index 0ab5e8847..3330a70ee 100644 --- a/priv/templates/lvn.swiftui.gen/core_components.ex +++ b/priv/templates/lvn.swiftui.gen/core_components.ex @@ -352,7 +352,7 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m {@rest} > {msg} - + """ end