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)