Skip to content

Commit 28a40b7

Browse files
Merge pull request #297 from swiftwasm/yt/dedicated-executor
Add WebWorkerDedicatedExecutor to run actors on a dedicated web worker
2 parents 68b2bde + f638d14 commit 28a40b7

File tree

11 files changed

+468
-4
lines changed

11 files changed

+468
-4
lines changed
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version: 6.0
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "Example",
7+
platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")],
8+
dependencies: [
9+
.package(path: "../../"),
10+
],
11+
targets: [
12+
.executableTarget(
13+
name: "MyApp",
14+
dependencies: [
15+
.product(name: "JavaScriptKit", package: "JavaScriptKit"),
16+
.product(name: "JavaScriptEventLoop", package: "JavaScriptKit"),
17+
]
18+
),
19+
]
20+
)

Examples/ActorOnWebWorker/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# WebWorker + Actor example
2+
3+
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:
4+
5+
```sh
6+
$ (
7+
set -eo pipefail; \
8+
V="$(swiftc --version | head -n1)"; \
9+
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]')"; \
10+
curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \
11+
jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x
12+
)
13+
$ export SWIFT_SDK_ID=$(
14+
V="$(swiftc --version | head -n1)"; \
15+
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]')"; \
16+
curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \
17+
jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"]["id"]'
18+
)
19+
$ ./build.sh
20+
$ npx serve
21+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import JavaScriptEventLoop
2+
import JavaScriptKit
3+
4+
// Simple full-text search service
5+
actor SearchService {
6+
struct Error: Swift.Error, CustomStringConvertible {
7+
let message: String
8+
9+
var description: String {
10+
return self.message
11+
}
12+
}
13+
14+
let serialExecutor: any SerialExecutor
15+
16+
// Simple in-memory index: word -> positions
17+
var index: [String: [Int]] = [:]
18+
var originalContent: String = ""
19+
lazy var console: JSValue = {
20+
JSObject.global.console
21+
}()
22+
23+
nonisolated var unownedExecutor: UnownedSerialExecutor {
24+
return self.serialExecutor.asUnownedSerialExecutor()
25+
}
26+
27+
init(serialExecutor: any SerialExecutor) {
28+
self.serialExecutor = serialExecutor
29+
}
30+
31+
// Utility function for fetch
32+
func fetch(_ url: String) -> JSPromise {
33+
let jsFetch = JSObject.global.fetch.function!
34+
return JSPromise(jsFetch(url).object!)!
35+
}
36+
37+
func fetchAndIndex(url: String) async throws {
38+
let response = try await fetch(url).value()
39+
if response.status != 200 {
40+
throw Error(message: "Failed to fetch content")
41+
}
42+
let text = try await JSPromise(response.text().object!)!.value()
43+
let content = text.string!
44+
index(content)
45+
}
46+
47+
func index(_ contents: String) {
48+
self.originalContent = contents
49+
self.index = [:]
50+
51+
// Simple tokenization and indexing
52+
var position = 0
53+
let words = contents.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber })
54+
55+
for word in words {
56+
let wordStr = String(word)
57+
if wordStr.count > 1 { // Skip single-character words
58+
if index[wordStr] == nil {
59+
index[wordStr] = []
60+
}
61+
index[wordStr]?.append(position)
62+
}
63+
position += 1
64+
}
65+
66+
_ = console.log("Indexing complete with", index.count, "unique words")
67+
}
68+
69+
func search(_ query: String) -> [SearchResult] {
70+
let queryWords = query.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber })
71+
72+
if queryWords.isEmpty {
73+
return []
74+
}
75+
76+
var results: [SearchResult] = []
77+
78+
// Start with the positions of the first query word
79+
guard let firstWord = queryWords.first,
80+
let firstWordPositions = index[String(firstWord)]
81+
else {
82+
return []
83+
}
84+
85+
for position in firstWordPositions {
86+
// Extract context around this position
87+
let words = originalContent.lowercased().split(whereSeparator: {
88+
!$0.isLetter && !$0.isNumber
89+
})
90+
var contextWords: [String] = []
91+
92+
// Get words for context (5 words before, 10 words after)
93+
let contextStart = max(0, position - 5)
94+
let contextEnd = min(position + 10, words.count - 1)
95+
96+
if contextStart <= contextEnd && contextStart < words.count {
97+
for i in contextStart...contextEnd {
98+
if i < words.count {
99+
contextWords.append(String(words[i]))
100+
}
101+
}
102+
}
103+
104+
let context = contextWords.joined(separator: " ")
105+
results.append(SearchResult(position: position, context: context))
106+
}
107+
108+
return results
109+
}
110+
}
111+
112+
struct SearchResult {
113+
let position: Int
114+
let context: String
115+
}
116+
117+
@MainActor
118+
final class App {
119+
private let document = JSObject.global.document
120+
private let alert = JSObject.global.alert.function!
121+
122+
// UI elements
123+
private var container: JSValue
124+
private var urlInput: JSValue
125+
private var indexButton: JSValue
126+
private var searchInput: JSValue
127+
private var searchButton: JSValue
128+
private var statusElement: JSValue
129+
private var resultsElement: JSValue
130+
131+
// Search service
132+
private let service: SearchService
133+
134+
init(service: SearchService) {
135+
self.service = service
136+
container = document.getElementById("container")
137+
urlInput = document.getElementById("urlInput")
138+
indexButton = document.getElementById("indexButton")
139+
searchInput = document.getElementById("searchInput")
140+
searchButton = document.getElementById("searchButton")
141+
statusElement = document.getElementById("status")
142+
resultsElement = document.getElementById("results")
143+
setupEventHandlers()
144+
}
145+
146+
private func setupEventHandlers() {
147+
indexButton.onclick = .object(JSClosure { [weak self] _ in
148+
guard let self else { return .undefined }
149+
self.performIndex()
150+
return .undefined
151+
})
152+
153+
searchButton.onclick = .object(JSClosure { [weak self] _ in
154+
guard let self else { return .undefined }
155+
self.performSearch()
156+
return .undefined
157+
})
158+
}
159+
160+
private func performIndex() {
161+
let url = urlInput.value.string!
162+
163+
if url.isEmpty {
164+
alert("Please enter a URL")
165+
return
166+
}
167+
168+
updateStatus("Downloading and indexing content...")
169+
170+
Task { [weak self] in
171+
guard let self else { return }
172+
do {
173+
try await self.service.fetchAndIndex(url: url)
174+
await MainActor.run {
175+
self.updateStatus("Indexing complete!")
176+
}
177+
} catch {
178+
await MainActor.run {
179+
self.updateStatus("Error: \(error)")
180+
}
181+
}
182+
}
183+
}
184+
185+
private func performSearch() {
186+
let query = searchInput.value.string!
187+
188+
if query.isEmpty {
189+
alert("Please enter a search query")
190+
return
191+
}
192+
193+
updateStatus("Searching...")
194+
195+
Task { [weak self] in
196+
guard let self else { return }
197+
let searchResults = await self.service.search(query)
198+
await MainActor.run {
199+
self.displaySearchResults(searchResults)
200+
}
201+
}
202+
}
203+
204+
private func updateStatus(_ message: String) {
205+
statusElement.innerText = .string(message)
206+
}
207+
208+
private func displaySearchResults(_ results: [SearchResult]) {
209+
statusElement.innerText = .string("Search complete! Found \(results.count) results.")
210+
resultsElement.innerHTML = .string("")
211+
212+
if results.isEmpty {
213+
var noResults = document.createElement("p")
214+
noResults.innerText = .string("No results found.")
215+
_ = resultsElement.appendChild(noResults)
216+
} else {
217+
// Display up to 10 results
218+
for (index, result) in results.prefix(10).enumerated() {
219+
var resultItem = document.createElement("div")
220+
resultItem.style = .string(
221+
"padding: 10px; margin: 5px 0; background: #f5f5f5; border-left: 3px solid blue;"
222+
)
223+
resultItem.innerHTML = .string(
224+
"<strong>Result \(index + 1):</strong> \(result.context)")
225+
_ = resultsElement.appendChild(resultItem)
226+
}
227+
}
228+
}
229+
}
230+
231+
@main struct Main {
232+
@MainActor static var app: App?
233+
234+
static func main() {
235+
JavaScriptEventLoop.installGlobalExecutor()
236+
WebWorkerTaskExecutor.installGlobalExecutor()
237+
238+
Task {
239+
// Create dedicated worker and search service
240+
let dedicatedWorker = try await WebWorkerDedicatedExecutor()
241+
let service = SearchService(serialExecutor: dedicatedWorker)
242+
app = App(service: service)
243+
}
244+
}
245+
}
246+
247+
#if canImport(wasi_pthread)
248+
import wasi_pthread
249+
import WASILibc
250+
251+
/// Trick to avoid blocking the main thread. pthread_mutex_lock function is used by
252+
/// the Swift concurrency runtime.
253+
@_cdecl("pthread_mutex_lock")
254+
func pthread_mutex_lock(_ mutex: UnsafeMutablePointer<pthread_mutex_t>) -> Int32 {
255+
// DO NOT BLOCK MAIN THREAD
256+
var ret: Int32
257+
repeat {
258+
ret = pthread_mutex_trylock(mutex)
259+
} while ret == EBUSY
260+
return ret
261+
}
262+
#endif

Examples/ActorOnWebWorker/build.sh

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
swift package --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -c release \
2+
plugin --allow-writing-to-package-directory \
3+
js --use-cdn --output ./Bundle

Examples/ActorOnWebWorker/index.html

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<html>
2+
3+
<head>
4+
<meta charset="utf-8">
5+
<title>WebWorker + Actor example</title>
6+
</head>
7+
8+
<body>
9+
<script type="module">
10+
import { init } from "./Bundle/index.js"
11+
init(fetch(new URL("./Bundle/main.wasm", import.meta.url)));
12+
</script>
13+
<h1>Full-text Search with Actor on Web Worker</h1>
14+
15+
<div id="container">
16+
<input type="text" id="urlInput"
17+
value="https://raw.githubusercontent.com/swiftlang/swift/refs/tags/swift-DEVELOPMENT-SNAPSHOT-2025-03-13-a/docs/SIL/Instructions.md"
18+
placeholder="Enter URL with text to index"
19+
style="width: 300px; padding: 8px; margin-right: 10px;">
20+
<button id="indexButton" style="padding: 8px 15px; margin-right: 10px;">Download & Index</button>
21+
<br>
22+
<br>
23+
<input type="text" id="searchInput" placeholder="Enter search query"
24+
style="width: 300px; padding: 8px; margin-right: 10px;">
25+
<button id="searchButton" style="padding: 8px 15px;">Search</button>
26+
<p id="status">Ready</p>
27+
<div id="results"></div>
28+
</div>
29+
</body>
30+
31+
</html>

Examples/ActorOnWebWorker/serve.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"headers": [{
3+
"source": "**/*",
4+
"headers": [
5+
{
6+
"key": "Cross-Origin-Embedder-Policy",
7+
"value": "require-corp"
8+
}, {
9+
"key": "Cross-Origin-Opener-Policy",
10+
"value": "same-origin"
11+
}
12+
]
13+
}]
14+
}

Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift

+18
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,24 @@ public extension JSPromise {
232232
}
233233
}
234234

235+
/// Wait for the promise to complete, returning its result or exception as a Result.
236+
///
237+
/// - Note: Calling this function does not switch from the caller's isolation domain.
238+
func value(isolation: isolated (any Actor)? = #isolation) async throws -> JSValue {
239+
try await withUnsafeThrowingContinuation(isolation: isolation) { [self] continuation in
240+
self.then(
241+
success: {
242+
continuation.resume(returning: $0)
243+
return JSValue.undefined
244+
},
245+
failure: {
246+
continuation.resume(throwing: JSException($0))
247+
return JSValue.undefined
248+
}
249+
)
250+
}
251+
}
252+
235253
/// Wait for the promise to complete, returning its result or exception as a Result.
236254
var result: JSPromise.Result {
237255
get async {

0 commit comments

Comments
 (0)