diff --git a/Examples/ActorOnWebWorker/Package.swift b/Examples/ActorOnWebWorker/Package.swift new file mode 100644 index 000000000..711bf6461 --- /dev/null +++ b/Examples/ActorOnWebWorker/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Example", + platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")], + dependencies: [ + .package(path: "../../"), + ], + targets: [ + .executableTarget( + name: "MyApp", + dependencies: [ + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + ] + ), + ] +) diff --git a/Examples/ActorOnWebWorker/README.md b/Examples/ActorOnWebWorker/README.md new file mode 100644 index 000000000..c0c849962 --- /dev/null +++ b/Examples/ActorOnWebWorker/README.md @@ -0,0 +1,21 @@ +# WebWorker + Actor example + +Install Development Snapshot toolchain `DEVELOPMENT-SNAPSHOT-2024-07-08-a` or later from [swift.org/install](https://www.swift.org/install/) and run the following commands: + +```sh +$ ( + set -eo pipefail; \ + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x +) +$ export SWIFT_SDK_ID=$( + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"]["id"]' +) +$ ./build.sh +$ npx serve +``` diff --git a/Examples/ActorOnWebWorker/Sources/MyApp.swift b/Examples/ActorOnWebWorker/Sources/MyApp.swift new file mode 100644 index 000000000..7d362d13e --- /dev/null +++ b/Examples/ActorOnWebWorker/Sources/MyApp.swift @@ -0,0 +1,262 @@ +import JavaScriptEventLoop +import JavaScriptKit + +// Simple full-text search service +actor SearchService { + struct Error: Swift.Error, CustomStringConvertible { + let message: String + + var description: String { + return self.message + } + } + + let serialExecutor: any SerialExecutor + + // Simple in-memory index: word -> positions + var index: [String: [Int]] = [:] + var originalContent: String = "" + lazy var console: JSValue = { + JSObject.global.console + }() + + nonisolated var unownedExecutor: UnownedSerialExecutor { + return self.serialExecutor.asUnownedSerialExecutor() + } + + init(serialExecutor: any SerialExecutor) { + self.serialExecutor = serialExecutor + } + + // Utility function for fetch + func fetch(_ url: String) -> JSPromise { + let jsFetch = JSObject.global.fetch.function! + return JSPromise(jsFetch(url).object!)! + } + + func fetchAndIndex(url: String) async throws { + let response = try await fetch(url).value() + if response.status != 200 { + throw Error(message: "Failed to fetch content") + } + let text = try await JSPromise(response.text().object!)!.value() + let content = text.string! + index(content) + } + + func index(_ contents: String) { + self.originalContent = contents + self.index = [:] + + // Simple tokenization and indexing + var position = 0 + let words = contents.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + + for word in words { + let wordStr = String(word) + if wordStr.count > 1 { // Skip single-character words + if index[wordStr] == nil { + index[wordStr] = [] + } + index[wordStr]?.append(position) + } + position += 1 + } + + _ = console.log("Indexing complete with", index.count, "unique words") + } + + func search(_ query: String) -> [SearchResult] { + let queryWords = query.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + + if queryWords.isEmpty { + return [] + } + + var results: [SearchResult] = [] + + // Start with the positions of the first query word + guard let firstWord = queryWords.first, + let firstWordPositions = index[String(firstWord)] + else { + return [] + } + + for position in firstWordPositions { + // Extract context around this position + let words = originalContent.lowercased().split(whereSeparator: { + !$0.isLetter && !$0.isNumber + }) + var contextWords: [String] = [] + + // Get words for context (5 words before, 10 words after) + let contextStart = max(0, position - 5) + let contextEnd = min(position + 10, words.count - 1) + + if contextStart <= contextEnd && contextStart < words.count { + for i in contextStart...contextEnd { + if i < words.count { + contextWords.append(String(words[i])) + } + } + } + + let context = contextWords.joined(separator: " ") + results.append(SearchResult(position: position, context: context)) + } + + return results + } +} + +struct SearchResult { + let position: Int + let context: String +} + +@MainActor +final class App { + private let document = JSObject.global.document + private let alert = JSObject.global.alert.function! + + // UI elements + private var container: JSValue + private var urlInput: JSValue + private var indexButton: JSValue + private var searchInput: JSValue + private var searchButton: JSValue + private var statusElement: JSValue + private var resultsElement: JSValue + + // Search service + private let service: SearchService + + init(service: SearchService) { + self.service = service + container = document.getElementById("container") + urlInput = document.getElementById("urlInput") + indexButton = document.getElementById("indexButton") + searchInput = document.getElementById("searchInput") + searchButton = document.getElementById("searchButton") + statusElement = document.getElementById("status") + resultsElement = document.getElementById("results") + setupEventHandlers() + } + + private func setupEventHandlers() { + indexButton.onclick = .object(JSClosure { [weak self] _ in + guard let self else { return .undefined } + self.performIndex() + return .undefined + }) + + searchButton.onclick = .object(JSClosure { [weak self] _ in + guard let self else { return .undefined } + self.performSearch() + return .undefined + }) + } + + private func performIndex() { + let url = urlInput.value.string! + + if url.isEmpty { + alert("Please enter a URL") + return + } + + updateStatus("Downloading and indexing content...") + + Task { [weak self] in + guard let self else { return } + do { + try await self.service.fetchAndIndex(url: url) + await MainActor.run { + self.updateStatus("Indexing complete!") + } + } catch { + await MainActor.run { + self.updateStatus("Error: \(error)") + } + } + } + } + + private func performSearch() { + let query = searchInput.value.string! + + if query.isEmpty { + alert("Please enter a search query") + return + } + + updateStatus("Searching...") + + Task { [weak self] in + guard let self else { return } + let searchResults = await self.service.search(query) + await MainActor.run { + self.displaySearchResults(searchResults) + } + } + } + + private func updateStatus(_ message: String) { + statusElement.innerText = .string(message) + } + + private func displaySearchResults(_ results: [SearchResult]) { + statusElement.innerText = .string("Search complete! Found \(results.count) results.") + resultsElement.innerHTML = .string("") + + if results.isEmpty { + var noResults = document.createElement("p") + noResults.innerText = .string("No results found.") + _ = resultsElement.appendChild(noResults) + } else { + // Display up to 10 results + for (index, result) in results.prefix(10).enumerated() { + var resultItem = document.createElement("div") + resultItem.style = .string( + "padding: 10px; margin: 5px 0; background: #f5f5f5; border-left: 3px solid blue;" + ) + resultItem.innerHTML = .string( + "Result \(index + 1): \(result.context)") + _ = resultsElement.appendChild(resultItem) + } + } + } +} + +@main struct Main { + @MainActor static var app: App? + + static func main() { + JavaScriptEventLoop.installGlobalExecutor() + WebWorkerTaskExecutor.installGlobalExecutor() + + Task { + // Create dedicated worker and search service + let dedicatedWorker = try await WebWorkerDedicatedExecutor() + let service = SearchService(serialExecutor: dedicatedWorker) + app = App(service: service) + } + } +} + +#if canImport(wasi_pthread) + import wasi_pthread + import WASILibc + + /// Trick to avoid blocking the main thread. pthread_mutex_lock function is used by + /// the Swift concurrency runtime. + @_cdecl("pthread_mutex_lock") + func pthread_mutex_lock(_ mutex: UnsafeMutablePointer) -> Int32 { + // DO NOT BLOCK MAIN THREAD + var ret: Int32 + repeat { + ret = pthread_mutex_trylock(mutex) + } while ret == EBUSY + return ret + } +#endif diff --git a/Examples/ActorOnWebWorker/build.sh b/Examples/ActorOnWebWorker/build.sh new file mode 100755 index 000000000..c82a10c32 --- /dev/null +++ b/Examples/ActorOnWebWorker/build.sh @@ -0,0 +1,3 @@ +swift package --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -c release \ + plugin --allow-writing-to-package-directory \ + js --use-cdn --output ./Bundle diff --git a/Examples/ActorOnWebWorker/index.html b/Examples/ActorOnWebWorker/index.html new file mode 100644 index 000000000..2797702e1 --- /dev/null +++ b/Examples/ActorOnWebWorker/index.html @@ -0,0 +1,31 @@ + + + + + WebWorker + Actor example + + + + +

Full-text Search with Actor on Web Worker

+ +
+ + +
+
+ + +

Ready

+
+
+ + + diff --git a/Examples/ActorOnWebWorker/serve.json b/Examples/ActorOnWebWorker/serve.json new file mode 100644 index 000000000..537a16904 --- /dev/null +++ b/Examples/ActorOnWebWorker/serve.json @@ -0,0 +1,14 @@ +{ + "headers": [{ + "source": "**/*", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + }, { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + }] +} diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift index 07eec2cd2..ce4fb1047 100644 --- a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift @@ -232,6 +232,24 @@ public extension JSPromise { } } + /// Wait for the promise to complete, returning its result or exception as a Result. + /// + /// - Note: Calling this function does not switch from the caller's isolation domain. + func value(isolation: isolated (any Actor)? = #isolation) async throws -> JSValue { + try await withUnsafeThrowingContinuation(isolation: isolation) { [self] continuation in + self.then( + success: { + continuation.resume(returning: $0) + return JSValue.undefined + }, + failure: { + continuation.resume(throwing: JSException($0)) + return JSValue.undefined + } + ) + } + } + /// Wait for the promise to complete, returning its result or exception as a Result. var result: JSPromise.Result { get async { diff --git a/Sources/JavaScriptEventLoop/WebWorkerDedicatedExecutor.swift b/Sources/JavaScriptEventLoop/WebWorkerDedicatedExecutor.swift new file mode 100644 index 000000000..695eb9c61 --- /dev/null +++ b/Sources/JavaScriptEventLoop/WebWorkerDedicatedExecutor.swift @@ -0,0 +1,60 @@ +import JavaScriptKit +import _CJavaScriptEventLoop + +#if canImport(Synchronization) + import Synchronization +#endif +#if canImport(wasi_pthread) + import wasi_pthread + import WASILibc +#endif + +/// A serial executor that runs on a dedicated web worker thread. +/// +/// This executor is useful for running actors on a dedicated web worker thread. +/// +/// ## Usage +/// +/// ```swift +/// actor MyActor { +/// let executor: WebWorkerDedicatedExecutor +/// nonisolated var unownedExecutor: UnownedSerialExecutor { +/// self.executor.asUnownedSerialExecutor() +/// } +/// init(executor: WebWorkerDedicatedExecutor) { +/// self.executor = executor +/// } +/// } +/// +/// let executor = try await WebWorkerDedicatedExecutor() +/// let actor = MyActor(executor: executor) +/// ``` +/// +/// - SeeAlso: ``WebWorkerTaskExecutor`` +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public final class WebWorkerDedicatedExecutor: SerialExecutor { + + private let underlying: WebWorkerTaskExecutor + + /// - Parameters: + /// - timeout: The maximum time to wait for all worker threads to be started. Default is 3 seconds. + /// - checkInterval: The interval to check if all worker threads are started. Default is 5 microseconds. + /// - Throws: An error if any worker thread fails to initialize within the timeout period. + public init(timeout: Duration = .seconds(3), checkInterval: Duration = .microseconds(5)) async throws { + let underlying = try await WebWorkerTaskExecutor( + numberOfThreads: 1, timeout: timeout, checkInterval: checkInterval + ) + self.underlying = underlying + } + + /// Terminates the worker thread. + public func terminate() { + self.underlying.terminate() + } + + // MARK: - SerialExecutor conformance + + public func enqueue(_ job: consuming ExecutorJob) { + self.underlying.enqueue(job) + } +} diff --git a/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift b/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift index 4c441f3c4..0582fe8c4 100644 --- a/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift +++ b/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift @@ -27,6 +27,11 @@ import JavaScriptEventLoop func swift_javascriptkit_activate_js_executor_impl() { MainActor.assumeIsolated { JavaScriptEventLoop.installGlobalExecutor() + #if canImport(wasi_pthread) && compiler(>=6.1) && _runtime(_multithreaded) + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + WebWorkerTaskExecutor.installGlobalExecutor() + } + #endif } } diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerDedicatedExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerDedicatedExecutorTests.swift new file mode 100644 index 000000000..b6c2bd8db --- /dev/null +++ b/Tests/JavaScriptEventLoopTests/WebWorkerDedicatedExecutorTests.swift @@ -0,0 +1,34 @@ +#if compiler(>=6.1) && _runtime(_multithreaded) +import XCTest +@testable import JavaScriptEventLoop + +final class WebWorkerDedicatedExecutorTests: XCTestCase { + actor MyActor { + let executor: WebWorkerDedicatedExecutor + nonisolated var unownedExecutor: UnownedSerialExecutor { + self.executor.asUnownedSerialExecutor() + } + + init(executor: WebWorkerDedicatedExecutor) { + self.executor = executor + XCTAssertTrue(isMainThread()) + } + + func onWorkerThread() async { + XCTAssertFalse(isMainThread()) + await Task.detached {}.value + // Should keep on the thread after back from the other isolation domain + XCTAssertFalse(isMainThread()) + } + } + + func testEnqueue() async throws { + let executor = try await WebWorkerDedicatedExecutor() + defer { executor.terminate() } + let actor = MyActor(executor: executor) + XCTAssertTrue(isMainThread()) + await actor.onWorkerThread() + XCTAssertTrue(isMainThread()) + } +} +#endif diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 0dfdac25f..1696224df 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -23,10 +23,6 @@ func pthread_mutex_lock(_ mutex: UnsafeMutablePointer) -> Int32 #endif final class WebWorkerTaskExecutorTests: XCTestCase { - override func setUp() async { - WebWorkerTaskExecutor.installGlobalExecutor() - } - func testTaskRunOnMainThread() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)