Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WebWorkerDedicatedExecutor to run actors on a dedicated web worker #297

Merged
merged 6 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Examples/ActorOnWebWorker/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
21 changes: 21 additions & 0 deletions Examples/ActorOnWebWorker/README.md
Original file line number Diff line number Diff line change
@@ -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
```
262 changes: 262 additions & 0 deletions Examples/ActorOnWebWorker/Sources/MyApp.swift
Original file line number Diff line number Diff line change
@@ -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(
"<strong>Result \(index + 1):</strong> \(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<pthread_mutex_t>) -> Int32 {
// DO NOT BLOCK MAIN THREAD
var ret: Int32
repeat {
ret = pthread_mutex_trylock(mutex)
} while ret == EBUSY
return ret
}
#endif
3 changes: 3 additions & 0 deletions Examples/ActorOnWebWorker/build.sh
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions Examples/ActorOnWebWorker/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<html>

<head>
<meta charset="utf-8">
<title>WebWorker + Actor example</title>
</head>

<body>
<script type="module">
import { init } from "./Bundle/index.js"
init(fetch(new URL("./Bundle/main.wasm", import.meta.url)));
</script>
<h1>Full-text Search with Actor on Web Worker</h1>

<div id="container">
<input type="text" id="urlInput"
value="https://raw.githubusercontent.com/swiftlang/swift/refs/tags/swift-DEVELOPMENT-SNAPSHOT-2025-03-13-a/docs/SIL/Instructions.md"
placeholder="Enter URL with text to index"
style="width: 300px; padding: 8px; margin-right: 10px;">
<button id="indexButton" style="padding: 8px 15px; margin-right: 10px;">Download & Index</button>
<br>
<br>
<input type="text" id="searchInput" placeholder="Enter search query"
style="width: 300px; padding: 8px; margin-right: 10px;">
<button id="searchButton" style="padding: 8px 15px;">Search</button>
<p id="status">Ready</p>
<div id="results"></div>
</div>
</body>

</html>
14 changes: 14 additions & 0 deletions Examples/ActorOnWebWorker/serve.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"headers": [{
"source": "**/*",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
}, {
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
}
]
}]
}
18 changes: 18 additions & 0 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading