diff --git a/Sources/carton/Combine/Builder.swift b/Sources/carton/Combine/Builder.swift index 83bc885..426ab0d 100644 --- a/Sources/carton/Combine/Builder.swift +++ b/Sources/carton/Combine/Builder.swift @@ -21,30 +21,63 @@ struct BuilderError: Error, CustomStringConvertible { let description: String } -struct Builder { +final class Builder { let publisher: AnyPublisher - private let process: TSCBasic.Process - - init(_ arguments: [String]) { + init(_ arguments: [String], _ terminal: TerminalController) { let subject = PassthroughSubject() - publisher = subject.eraseToAnyPublisher() + publisher = subject + .handleEvents(receiveOutput: { + terminal.write($0) + }, receiveCompletion: { + switch $0 { + case .finished: + terminal.write("Build completed successfully\n\n", inColor: .green, bold: false) + case let .failure(error): + let errorString = String(describing: error) + if errorString.isEmpty { + terminal.write("Build failed, check the build process output above.\n", inColor: .red) + } else { + terminal.write("Build failed and produced following output: \n", inColor: .red) + print(error) + } + } + }) + .eraseToAnyPublisher() - let stdout: TSCBasic.Process.OutputClosure = { - guard let string = String(data: Data($0), encoding: .utf8) else { return } - subject.send(string) + DispatchQueue.global().async { + let stdout: TSCBasic.Process.OutputClosure = { + guard let string = String(data: Data($0), encoding: .utf8) else { return } + subject.send(string) + } + + var stderrBuffer = [UInt8]() + + let stderr: TSCBasic.Process.OutputClosure = { + stderrBuffer.append(contentsOf: $0) + } + + let process = Process( + arguments: arguments, + outputRedirection: .stream(stdout: stdout, stderr: stderr), + verbose: true, + startNewProcessGroup: true + ) + + let result = Result { + try process.launch() + return try process.waitUntilExit() + } + + switch result.map(\.exitStatus) { + case .success(.terminated(code: 0)): + subject.send(completion: .finished) + case let .failure(error): + subject.send(completion: .failure(error)) + default: + let errorDescription = String(data: Data(stderrBuffer), encoding: .utf8) ?? "" + return subject.send(completion: .failure(BuilderError(description: errorDescription))) + } } - - let stderr: TSCBasic.Process.OutputClosure = { - guard let string = String(data: Data($0), encoding: .utf8) else { return } - subject.send(completion: .failure(BuilderError(description: string))) - } - - process = Process( - arguments: arguments, - outputRedirection: .stream(stdout: stdout, stderr: stderr), - verbose: true, - startNewProcessGroup: true - ) } } diff --git a/Sources/carton/Combine/Catch.swift b/Sources/carton/Combine/Catch.swift new file mode 100644 index 0000000..82b806f --- /dev/null +++ b/Sources/carton/Combine/Catch.swift @@ -0,0 +1,332 @@ +// MIT License + +// Copyright (c) 2019 Sergej Jaskiewicz + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ┃ +// ┃ Auto-generated from GYB template. DO NOT EDIT! ┃ +// ┃ ┃ +// ┃ ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +// +// Publishers.Catch.swift +// +// +// Created by Sergej Jaskiewicz on 25.12.2019. +// + +import COpenCombineHelpers +import OpenCombine + +extension Publisher { + /// Handles errors from an upstream publisher by replacing it with another publisher. + /// + /// The following example replaces any error from the upstream publisher and replaces + /// the upstream with a `Just` publisher. This continues the stream by publishing + /// a single value and completing normally. + /// ``` + /// enum SimpleError: Error { case error } + /// let errorPublisher = (0..<10).publisher.tryMap { v -> Int in + /// if v < 5 { + /// return v + /// } else { + /// throw SimpleError.error + /// } + /// } + /// + /// let noErrorPublisher = errorPublisher.catch { _ in + /// return Just(100) + /// } + /// ``` + /// Backpressure note: This publisher passes through `request` and `cancel` to + /// the upstream. After receiving an error, the publisher sends sends any unfulfilled + /// demand to the new `Publisher`. + /// + /// - Parameter handler: A closure that accepts the upstream failure as input and + /// returns a publisher to replace the upstream publisher. + /// - Returns: A publisher that handles errors from an upstream publisher by replacing + /// the failed publisher with another publisher. + public func `catch`( + _ handler: @escaping (Failure) -> NewPublisher + ) -> Publishers.Catch + where NewPublisher.Output == Output { + return .init(upstream: self, handler: handler) + } +} + +extension Publishers { + /// A publisher that handles errors from an upstream publisher by replacing the failed + /// publisher with another publisher. + public struct Catch: Publisher + where Upstream.Output == NewPublisher.Output { + public typealias Output = Upstream.Output + + public typealias Failure = NewPublisher.Failure + + /// The publisher that this publisher receives elements from. + public let upstream: Upstream + + /// A closure that accepts the upstream failure as input and returns a publisher + /// to replace the upstream publisher. + public let handler: (Upstream.Failure) -> NewPublisher + + /// Creates a publisher that handles errors from an upstream publisher by + /// replacing the failed publisher with another publisher. + /// + /// - Parameters: + /// - upstream: The publisher that this publisher receives elements from. + /// - handler: A closure that accepts the upstream failure as input and returns + /// a publisher to replace the upstream publisher. + public init(upstream: Upstream, + handler: @escaping (Upstream.Failure) -> NewPublisher) { + self.upstream = upstream + self.handler = handler + } + + public func receive(subscriber: Downstream) + where Downstream.Input == Output, Downstream.Failure == Failure { + let inner = Inner(downstream: subscriber, handler: handler) + let uncaughtS = Inner.UncaughtS(inner: inner) + upstream.subscribe(uncaughtS) + } + } +} + +extension Publishers.Catch { + private final class Inner: + Subscription, + CustomStringConvertible, + CustomReflectable, + CustomPlaygroundDisplayConvertible + where Downstream.Input == Upstream.Output, + Downstream.Failure == NewPublisher.Failure { + struct UncaughtS: Subscriber, + CustomStringConvertible, + CustomReflectable, + CustomPlaygroundDisplayConvertible { + typealias Input = Upstream.Output + + typealias Failure = Upstream.Failure + + let inner: Inner + + var combineIdentifier: CombineIdentifier { inner.combineIdentifier } + + func receive(subscription: Subscription) { + inner.receivePre(subscription: subscription) + } + + func receive(_ input: Input) -> Subscribers.Demand { + inner.receivePre(input) + } + + func receive(completion: Subscribers.Completion) { + inner.receivePre(completion: completion) + } + + var description: String { inner.description } + + var customMirror: Mirror { inner.customMirror } + + var playgroundDescription: Any { description } + } + + struct CaughtS: Subscriber, + CustomStringConvertible, + CustomReflectable, + CustomPlaygroundDisplayConvertible { + typealias Input = NewPublisher.Output + + typealias Failure = NewPublisher.Failure + + let inner: Inner + + var combineIdentifier: CombineIdentifier { inner.combineIdentifier } + + func receive(subscription: Subscription) { + inner.receivePost(subscription: subscription) + } + + func receive(_ input: Input) -> Subscribers.Demand { + inner.receivePost(input) + } + + func receive(completion: Subscribers.Completion) { + inner.receivePost(completion: completion) + } + + var description: String { inner.description } + + var customMirror: Mirror { inner.customMirror } + + var playgroundDescription: Any { description } + } + + private enum State { + case pendingPre + case pre(Subscription) + case pendingPost + case post(Subscription) + case cancelled + } + + private let lock = __UnfairLock.allocate() // 0x10 + private var demand = Subscribers.Demand.none // 0x18 + private var state = State.pendingPre // 0x20 + private let downstream: Downstream + + private let handler: (Upstream.Failure) -> NewPublisher + + init(downstream: Downstream, + handler: @escaping (Upstream.Failure) -> NewPublisher) { + self.downstream = downstream + self.handler = handler + } + + deinit { + lock.deallocate() + } + + func receivePre(subscription: Subscription) { + lock.lock() + guard case .pendingPre = state else { + lock.unlock() + subscription.cancel() + return + } + state = .pre(subscription) + lock.unlock() + downstream.receive(subscription: self) + } + + func receivePre(_ input: Upstream.Output) -> Subscribers.Demand { + lock.lock() + demand -= 1 + lock.unlock() + let newDemand = downstream.receive(input) + lock.lock() + demand += newDemand + lock.unlock() + return newDemand + } + + func receivePre(completion: Subscribers.Completion) { + switch completion { + case .finished: + lock.lock() + if case .pre = state { + state = .cancelled + lock.unlock() + downstream.receive(completion: .finished) + } else { + lock.unlock() + } + case let .failure(error): + lock.lock() + if case .pre = state { + state = .pendingPost + lock.unlock() + handler(error).subscribe(CaughtS(inner: self)) + } else { + lock.unlock() + } + } + } + + func receivePost(subscription: Subscription) { + lock.lock() + guard case .pendingPost = state else { + lock.unlock() + subscription.cancel() + return + } + state = .post(subscription) + let demand = self.demand + lock.unlock() + if demand > 0 { + subscription.request(demand) + } + } + + func receivePost(_ input: NewPublisher.Output) -> Subscribers.Demand { + downstream.receive(input) + } + + func receivePost(completion: Subscribers.Completion) { + lock.lock() + guard case .post = state else { + lock.unlock() + return + } + state = .cancelled + lock.unlock() + downstream.receive(completion: completion) + } + + func request(_ demand: Subscribers.Demand) { + if demand == .none { + fatalError("API Violation: demand must not be zero") + } + lock.lock() + switch state { + case .pendingPre: + lock.unlock() + case let .pre(subscription): + self.demand += demand + lock.unlock() + subscription.request(demand) + case .pendingPost: + self.demand += demand + lock.unlock() + case let .post(subscription): + lock.unlock() + subscription.request(demand) + case .cancelled: + lock.unlock() + } + } + + func cancel() { + lock.lock() + switch state { + case let .pre(subscription), let .post(subscription): + state = .cancelled + lock.unlock() + subscription.cancel() + default: + state = .cancelled + lock.unlock() + } + } + + var description: String { "Catch" } + + var customMirror: Mirror { + let children: [Mirror.Child] = [ + ("downstream", downstream), + ("demand", demand), + ] + return Mirror(self, children: children) + } + + var playgroundDescription: Any { description } + } +} diff --git a/Sources/carton/Combine/ProgressAnimation.swift b/Sources/carton/Combine/ProgressAnimation.swift index 3c1fc1a..fb6857a 100644 --- a/Sources/carton/Combine/ProgressAnimation.swift +++ b/Sources/carton/Combine/ProgressAnimation.swift @@ -31,7 +31,7 @@ extension Publisher where Output == Progress, Failure: CustomStringConvertible { switch $0 { case .finished: progressAnimation.complete(success: true) - terminal.write("Build finished succesfully\n", inColor: .green, bold: false) + terminal.write("Build finished succesfully\n", inColor: .green) case let .failure(error): progressAnimation.complete(success: false) terminal.write(error.description, inColor: .red, bold: false) diff --git a/Sources/carton/Dev.swift b/Sources/carton/Dev.swift index fd93e09..f06b701 100644 --- a/Sources/carton/Dev.swift +++ b/Sources/carton/Dev.swift @@ -14,6 +14,7 @@ import ArgumentParser import Foundation +import OpenCombine import TSCBasic func processDataOutput(_ arguments: [String]) throws -> Data { @@ -41,15 +42,17 @@ struct Dev: ParsableCommand { else { fatalError("failed to create an instance of `TerminalController`") } // try checkDevDependencies(on: localFileSystem, terminal) + let fm = FileManager.default + terminal.write("Inferring basic settings...\n", inColor: .yellow) let swiftPath: String if - let data = FileManager.default.contents(atPath: ".swift-version"), + let data = fm.contents(atPath: ".swift-version"), // get the first line of the file let swiftVersion = String(data: data, encoding: .utf8)?.components( separatedBy: CharacterSet.newlines ).first { - swiftPath = FileManager.default.homeDirectoryForCurrentUser + swiftPath = fm.homeDirectoryForCurrentUser .appending(".swiftenv", "versions", swiftVersion, "usr", "bin", "swift") .path } else { @@ -89,13 +92,33 @@ struct Dev: ParsableCommand { guard let sources = localFileSystem.currentWorkingDirectory?.appending(component: "Sources") else { fatalError("failed to infer the sources directory") } - terminal.write("\nWatching this directory for changes: ", inColor: .green, bold: false) + terminal.write("\nBuilding the project before spinning up a server...\n", inColor: .yellow) + + let builderArguments = [swiftPath, "build", "--triple", "wasm32-unknown-wasi"] + var subscription: AnyCancellable? + try await { completion in + subscription = Builder(builderArguments, terminal).publisher + .sink( + receiveCompletion: { _ in completion(Result<(), Never>.success(())) }, + receiveValue: { _ in } + ) + } + + guard fm.fileExists(atPath: mainWasmURL.path) else { + return terminal.write( + "Failed to build the main executable binary, fix the build errors and restart\n" + ) + } + + terminal.write("\nWatching this directory for changes: ", inColor: .green) terminal.logLookup("", sources) terminal.write("\n") try Server( + builderArguments: builderArguments, pathsToWatch: localFileSystem.traverseRecursively(sources), - mainWasmPath: mainWasmURL.path + mainWasmPath: mainWasmURL.path, + terminal ).run() } } diff --git a/Sources/carton/Server/Server.swift b/Sources/carton/Server/Server.swift index f68a466..666b677 100644 --- a/Sources/carton/Server/Server.swift +++ b/Sources/carton/Server/Server.swift @@ -22,9 +22,15 @@ final class Server { private var wsConnection: WebSocket? private var subscriptions = [AnyCancellable]() private let watcher: Watcher + private var builder: Builder? private let app: Application - init(pathsToWatch: [AbsolutePath], mainWasmPath: String) throws { + init( + builderArguments: [String], + pathsToWatch: [AbsolutePath], + mainWasmPath: String, + _ terminal: TerminalController + ) throws { watcher = try Watcher(pathsToWatch) var env = Environment.development @@ -35,9 +41,24 @@ final class Server { } watcher.publisher - .sink { [weak self] _ in - self?.wsConnection?.send("reload") + .flatMap(maxPublishers: .max(1)) { changes -> AnyPublisher in + terminal.write("\nThese paths have changed, rebuilding...\n", inColor: .yellow) + for change in changes.map(\.pathString) { + terminal.write("- \(change)\n", inColor: .cyan) + } + terminal.write("\n") + return Builder(builderArguments, terminal) + .publisher + .handleEvents(receiveCompletion: { [weak self] in + guard case .finished = $0 else { return } + self?.wsConnection?.send("reload") + }) + .catch { _ in Empty().eraseToAnyPublisher() } + .eraseToAnyPublisher() } + .sink( + receiveValue: { _ in } + ) .store(in: &subscriptions) } diff --git a/TestApp/Sources/TestApp/main.swift b/TestApp/Sources/TestApp/main.swift index 44913c0..3210dd9 100644 --- a/TestApp/Sources/TestApp/main.swift +++ b/TestApp/Sources/TestApp/main.swift @@ -22,4 +22,4 @@ divElement.innerText = "Hello, world" let body = document.body.object! _ = body.appendChild!(divElement) -print("hello, world") +print("Hello, world!") diff --git a/entrypoint/dev.js b/entrypoint/dev.js index 227b573..4674aca 100644 --- a/entrypoint/dev.js +++ b/entrypoint/dev.js @@ -16,7 +16,11 @@ const wasi = new WASI({ const socket = new WebSocket("ws://127.0.0.1:8080/watcher"); -socket.onmessage = (message) => console.log(message.data); +socket.onmessage = (message) => { + if (message.data === "reload") { + location.reload(); + } +}; const startWasiTask = async () => { // Fetch our Wasm File