├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── WebWorkerKit │ ├── JSValueEncoder.swift │ ├── WebWorker.swift │ ├── WebWorkerActorSystem+remoteCall.swift │ ├── WebWorkerActorSystem.swift │ ├── WebWorkerCallDecoder.swift │ ├── WebWorkerCallEncoder.swift │ ├── WebWorkerHost.swift │ ├── WebWorkerIdentity.swift │ ├── WebWorkerMessage.swift │ └── WebWorkerResultHandler.swift └── Tests └── WebWorkerKitTests └── WebWorkerKitTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 SwiftWasm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "WebWorkerKit", 6 | platforms: [.macOS(.v10_15), .iOS(.v13)], 7 | products: [ 8 | .library( 9 | name: "WebWorkerKit", 10 | targets: ["WebWorkerKit"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/swiftwasm/JavaScriptKit", from: "0.16.0"), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "WebWorkerKit", 18 | dependencies: [ 19 | "JavaScriptKit", 20 | .product(name: "JavaScriptEventLoop", package: "JavaScriptKit") 21 | ] 22 | ), 23 | .testTarget( 24 | name: "WebWorkerKitTests", 25 | dependencies: ["WebWorkerKit"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebWorkerKit 2 | 3 | This library allows you to control a WebWorker using Swift's `distributed actor` feature. It abstracts away the creation of the WebWorker itself, and allows you to communicate between "threads" in pure Swift, calling back and forth between them with any Codable, Sendable types (as required by `distributed actor`). 4 | 5 | It is used by [flowkey](https://www.flowkey.com)'s Web App to run realtime Audio DSP and ML in a background thread, so it is built with performance in mind. 6 | 7 | 8 | ## Usage Example 9 | 10 | ```main.swift 11 | import WebWorkerKit 12 | 13 | WebWorkerActorSystem.initialize() // important! sets up connection between WebWorkers and Main JS context (main thread). 14 | 15 | if !WebWorkerActorSystem.thisProcessIsAWebWorker { 16 | doNormalMainWork() 17 | } 18 | 19 | // Runs on "main thread" (main JS context) 20 | func doNormalMainWork() async throws { 21 | let myWorker = try MyDistributedActorWorker.new() 22 | let result = try await myWorker.doWork() // work will be performed within the Web Worker 23 | // ... use result ... 24 | } 25 | ``` 26 | 27 | ```MyDistributedActorWorker.swift 28 | import WebWorkerKit 29 | 30 | public struct SomeWorkResult: Codable, Sendable { // Codable, Sendable is important 31 | init(_ intermediateResults: Whatever) {...} 32 | // ... 33 | } 34 | 35 | distributed actor MyDistributedActorWorker: WebWorker { 36 | /// The JavaScript script URL to run that starts the (Swift Wasm) worker. Unless you know what you're doing, this *should* be `nil`. 37 | /// If `nil`, WebWorkerKit will find the same JS script that `main` was started with (usually this is what you want). 38 | static let scriptPath: String? = nil 39 | 40 | /// Specifies whether the JS script (set via the path above) is an ES-module or not. With WebWorkers, this needs to be set explicitly. 41 | static let isModule = false 42 | 43 | public distributed func doWork() async throws -> SomeWorkResult { 44 | let intermediateResults = try await calculateIntermediateResults() // this happens inside the web worker 45 | return SomeSendableWorkType(intermediateResults) // returned to "main" JS context 46 | } 47 | } 48 | ``` 49 | 50 | ## Bundling 51 | 52 | When `WebWorkerKit` starts a new worker (via `MyDistributedActorWorker.new()`), it starts a new instance of the JS bundle it was created with. i.e. It creates a WebWorker and loads `main.swift` again via JS. That's why it's important to wrap any "main thread only" work in `if !WebWorkerActorSystem.thisProcessIsAWebWorker` to avoid duplication. 53 | 54 | For that to work efficiently and smoothly, you'll need a JS bundle that loads and starts your Swift Wasm application, and nothing else. Carton and other simple bundlers will do this for you automatically – in those cases the entry point to your entire application *is* the Swift Wasm main bundle. 55 | 56 | To integrate WebWorkerKit into a web app that is not written in 100% Swift Wasm, configure your bundler to create a separate JS bundle (entry point) for just the Swift part of your app. That should be enough to ensure that only the Swift part will load when a second instance of the Swift bundle is created, and not the entire web app (which would likely fail due to missing APIs in the WebWorker JS context). 57 | 58 | 59 | ## Known Limitations 60 | 61 | flowkey's use case only requires a single, singleton, web worker instance per `WebWorker` type. Disallowing multiple separate `actor` instances is _not_ a technical limitation, we just didn't need it ourselves. We'd consider PRs that add that feature, provided the current functionality still remains. 62 | 63 | 64 | ## Future Experiments / Possibilities 65 | 66 | It's currently untested and unsupported, but rather than reusing the _same_ JS+Wasm bundle, it's probably possible to use this library to create _separate_ Swift bundles that are loaded asynchronously and independently (e.g. for a plugin system). In theory this just requires the `WebWorker`-conforming `distributed actor` type to be available and binary compatible in both bundles. 67 | 68 | To achieve this, you'd need to set the `scriptPath` static to the JS entrypoint that loads the separate Swift Wasm bundle. 69 | 70 | Let me know if you get this working and I'll give you a shoutout from this README. 71 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/JSValueEncoder.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | 3 | private let ArrayConstructor = JSObject.global.Array.function! 4 | private let ObjectConstructor = JSObject.global.Object.function! 5 | 6 | // TODO: Move this to JavaScriptKit 7 | public struct JSValueEncoder { 8 | public init() {} 9 | public func encode(_ value: T) throws -> JSValue { 10 | // Fast paths. 11 | // Without these, `Codable` will try to encode each value of the array 12 | // individually, which is orders of magnitudes slower. 13 | switch value { 14 | case let value as JSValue: 15 | return value 16 | case let value as [Double]: 17 | return JSTypedArray(value).jsValue 18 | case let value as [Float]: 19 | return JSTypedArray(value).jsValue 20 | case let value as [Int]: 21 | return JSTypedArray(value).jsValue 22 | case let value as [UInt]: 23 | return JSTypedArray(value).jsValue 24 | case let value as ConvertibleToJSValue: 25 | return value.jsValue 26 | default: break 27 | } 28 | 29 | let encoder = JSValueEncoderImpl(codingPath: []) 30 | try value.encode(to: encoder) 31 | return encoder.value 32 | } 33 | } 34 | 35 | private class JSValueEncoderImpl { 36 | let codingPath: [CodingKey] 37 | var value: JSValue = .undefined 38 | var userInfo: [CodingUserInfoKey : Any] = [:] 39 | 40 | init(codingPath: [CodingKey]) { 41 | self.codingPath = codingPath 42 | } 43 | } 44 | 45 | extension JSValueEncoderImpl: Encoder { 46 | func container(keyedBy _: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { 47 | self.value = .object(ObjectConstructor.new()) 48 | return KeyedEncodingContainer(JSObjectKeyedEncodingContainer(encoder: self)) 49 | } 50 | 51 | func singleValueContainer() -> SingleValueEncodingContainer { 52 | return SingleJSValueEncodingContainer(encoder: self) 53 | } 54 | 55 | func unkeyedContainer() -> UnkeyedEncodingContainer { 56 | self.value = .object(ArrayConstructor.new()) 57 | return JSUnkeyedEncodingContainer(encoder: self) 58 | } 59 | } 60 | 61 | private struct JSObjectKeyedEncodingContainer: KeyedEncodingContainerProtocol { 62 | var codingPath: [CodingKey] { 63 | return encoder.codingPath 64 | } 65 | 66 | let encoder: JSValueEncoderImpl 67 | init(encoder: JSValueEncoderImpl) { 68 | self.encoder = encoder 69 | } 70 | 71 | func encodeNil(forKey key: Key) throws { 72 | encoder.value[dynamicMember: key.stringValue] = .null 73 | } 74 | 75 | func encode(_ value: Bool, forKey key: Key) throws { 76 | encoder.value[dynamicMember: key.stringValue] = .boolean(value) 77 | } 78 | 79 | func encode(_ value: String, forKey key: Key) throws { 80 | encoder.value[dynamicMember: key.stringValue] = .string(value) 81 | } 82 | 83 | func encode(_ value: Double, forKey key: Key) throws { 84 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 85 | } 86 | 87 | func encode(_ value: Float, forKey key: Key) throws { 88 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 89 | } 90 | 91 | func encode(_ value: Int, forKey key: Key) throws { 92 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 93 | } 94 | 95 | func encode(_ value: Int8, forKey key: Key) throws { 96 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 97 | } 98 | 99 | func encode(_ value: Int16, forKey key: Key) throws { 100 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 101 | } 102 | 103 | func encode(_ value: Int32, forKey key: Key) throws { 104 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 105 | } 106 | 107 | func encode(_ value: Int64, forKey key: Key) throws { 108 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 109 | } 110 | 111 | func encode(_ value: UInt, forKey key: Key) throws { 112 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 113 | } 114 | 115 | func encode(_ value: UInt8, forKey key: Key) throws { 116 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 117 | } 118 | 119 | func encode(_ value: UInt16, forKey key: Key) throws { 120 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 121 | } 122 | 123 | func encode(_ value: UInt32, forKey key: Key) throws { 124 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 125 | } 126 | 127 | func encode(_ value: UInt64, forKey key: Key) throws { 128 | encoder.value[dynamicMember: key.stringValue] = .number(Double(value)) 129 | } 130 | 131 | func encode(_ value: T, forKey key: Key) throws where T : Encodable { 132 | encoder.value[dynamicMember: key.stringValue] = try JSValueEncoder().encode(value) 133 | } 134 | 135 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer where NestedKey: CodingKey 136 | { 137 | let nestedEncoder = JSValueEncoderImpl(codingPath: encoder.codingPath) 138 | let container = JSObjectKeyedEncodingContainer(encoder: nestedEncoder) 139 | nestedEncoder.value = .object(ObjectConstructor.new()) 140 | encoder.value[dynamicMember: key.stringValue] = nestedEncoder.value 141 | return KeyedEncodingContainer(container) 142 | } 143 | 144 | func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { 145 | preconditionFailure("??") 146 | } 147 | 148 | func superEncoder() -> Encoder { 149 | preconditionFailure("??") 150 | } 151 | 152 | func superEncoder(forKey key: Key) -> Encoder { 153 | preconditionFailure("??") 154 | } 155 | } 156 | 157 | private struct JSUnkeyedEncodingContainer: UnkeyedEncodingContainer { 158 | var codingPath: [CodingKey] { encoder.codingPath } 159 | 160 | let encoder: JSValueEncoderImpl 161 | init(encoder: JSValueEncoderImpl) { 162 | self.encoder = encoder 163 | } 164 | 165 | func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer where NestedKey : CodingKey 166 | { 167 | let encoder = JSValueEncoderImpl(codingPath: self.codingPath) 168 | return KeyedEncodingContainer( 169 | JSObjectKeyedEncodingContainer(encoder: encoder) 170 | ) 171 | } 172 | 173 | func superEncoder() -> Encoder { 174 | preconditionFailure("??") 175 | } 176 | 177 | func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { 178 | let newEncoder = JSValueEncoderImpl(codingPath: codingPath) // TODO add index to codingPath 179 | newEncoder.value = .object(ArrayConstructor.new()) 180 | return JSUnkeyedEncodingContainer(encoder: newEncoder) 181 | } 182 | 183 | var count: Int { Int(encoder.value.length.number!) } 184 | 185 | func encodeNil() throws { 186 | _ = encoder.value.push(JSValue.null) 187 | } 188 | 189 | func encode(_ value: Bool) throws { 190 | _ = encoder.value.push(JSValue.boolean(value)) 191 | } 192 | 193 | func encode(_ value: String) throws { 194 | _ = encoder.value.push(JSValue.string(value)) 195 | } 196 | 197 | func encode(_ value: Double) throws { 198 | _ = encoder.value.push(JSValue.number(Double(value))) 199 | } 200 | 201 | func encode(_ value: Float) throws { 202 | _ = encoder.value.push(JSValue.number(Double(value))) 203 | } 204 | 205 | func encode(_ value: Int) throws { 206 | _ = encoder.value.push(JSValue.number(Double(value))) 207 | } 208 | 209 | func encode(_ value: Int8) throws { 210 | _ = encoder.value.push(JSValue.number(Double(value))) 211 | } 212 | 213 | func encode(_ value: Int16) throws { 214 | _ = encoder.value.push(JSValue.number(Double(value))) 215 | } 216 | 217 | func encode(_ value: Int32) throws { 218 | _ = encoder.value.push(JSValue.number(Double(value))) 219 | } 220 | 221 | func encode(_ value: Int64) throws { 222 | _ = encoder.value.push(JSValue.number(Double(value))) 223 | } 224 | 225 | func encode(_ value: UInt) throws { 226 | _ = encoder.value.push(JSValue.number(Double(value))) 227 | } 228 | 229 | func encode(_ value: UInt8) throws { 230 | _ = encoder.value.push(JSValue.number(Double(value))) 231 | } 232 | 233 | func encode(_ value: UInt16) throws { 234 | _ = encoder.value.push(JSValue.number(Double(value))) 235 | } 236 | 237 | func encode(_ value: UInt32) throws { 238 | _ = encoder.value.push(JSValue.number(Double(value))) 239 | } 240 | 241 | func encode(_ value: UInt64) throws { 242 | _ = encoder.value.push(JSValue.number(Double(value))) 243 | } 244 | 245 | func encode(_ value: T) throws where T: Encodable { 246 | let newEncoder = JSValueEncoderImpl(codingPath: []) // TODO: coding path? 247 | try value.encode(to: newEncoder) 248 | _ = encoder.value.push(newEncoder.value) 249 | } 250 | } 251 | 252 | private struct SingleJSValueEncodingContainer: SingleValueEncodingContainer { 253 | var codingPath: [CodingKey] { encoder.codingPath } 254 | let encoder: JSValueEncoderImpl 255 | init(encoder: JSValueEncoderImpl) { 256 | self.encoder = encoder 257 | } 258 | 259 | func encode(_ value: T) throws where T: Encodable { 260 | encoder.value = try JSValueEncoder().encode(value) 261 | } 262 | 263 | public func encode(_ value: Bool) throws { 264 | encoder.value = .boolean(value) 265 | } 266 | 267 | public func encode(_ value: String) throws { 268 | encoder.value = .string(value) 269 | } 270 | 271 | public func encode(_ value: Double) throws { 272 | encoder.value = .number(value) 273 | } 274 | 275 | public func encode(_ value: Float) throws { 276 | encoder.value = .number(Double(value)) 277 | } 278 | 279 | public func encode(_ value: Int) throws { 280 | encoder.value = .number(Double(value)) 281 | } 282 | 283 | public func encode(_ value: Int8) throws { 284 | encoder.value = .number(Double(value)) 285 | } 286 | 287 | public func encode(_ value: Int16) throws { 288 | encoder.value = .number(Double(value)) 289 | } 290 | 291 | public func encode(_ value: Int32) throws { 292 | encoder.value = .number(Double(value)) 293 | } 294 | 295 | public func encode(_ value: Int64) throws { 296 | encoder.value = .number(Double(value)) 297 | } 298 | 299 | public func encode(_ value: UInt) throws { 300 | encoder.value = .number(Double(value)) 301 | } 302 | 303 | public func encode(_ value: UInt8) throws { 304 | encoder.value = .number(Double(value)) 305 | } 306 | 307 | public func encode(_ value: UInt16) throws { 308 | encoder.value = .number(Double(value)) 309 | } 310 | 311 | public func encode(_ value: UInt32) throws { 312 | encoder.value = .number(Double(value)) 313 | } 314 | 315 | public func encode(_ value: UInt64) throws { 316 | encoder.value = .number(Double(value)) 317 | } 318 | 319 | public func encodeNil() throws { 320 | encoder.value = .null 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorker.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | 4 | public protocol WebWorker: DistributedActor where ActorSystem == WebWorkerActorSystem, Self: Hashable { 5 | init(actorSystem: ActorSystem) 6 | 7 | /// Set a custom URL to initialize the web worker instance by. 8 | /// Defaults to the scriptPath the host (main thread) was loaded in. 9 | static var scriptPath: String? { get } 10 | 11 | /// Whether to load the worker script defined by `scriptPath` as an es-module 12 | static var isModule: Bool { get } 13 | } 14 | 15 | extension DistributedActor where ActorSystem == WebWorkerActorSystem { 16 | public static func new() throws -> Self { 17 | return try! Self.resolve( 18 | id: .singleton(for: Self.self), 19 | using: .shared 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerActorSystem+remoteCall.swift: -------------------------------------------------------------------------------- 1 | import Distributed 2 | import JavaScriptKit 3 | 4 | extension WebWorkerActorSystem { 5 | public func remoteCall( 6 | on actor: Act, 7 | target: RemoteCallTarget, 8 | invocation: inout InvocationEncoder, 9 | throwing: Err.Type, 10 | returning: Res.Type 11 | ) async throws -> Res 12 | where Act: DistributedActor, 13 | Act.ID == ActorID, 14 | Err: Error, 15 | Res: Codable 16 | { 17 | guard let replyData = try await withCallIDContinuation(recipient: actor, body: { callID in 18 | self.sendRemoteCall(to: actor, target: target, invocation: invocation, callID: callID) 19 | }) else { 20 | fatalError("Expected replyData but got `nil`") 21 | } 22 | 23 | do { 24 | let decoder = JSValueDecoder() 25 | return try decoder.decode(Res.self, from: replyData) 26 | } catch { 27 | assertionFailure("remoteCall: failed to decode response") 28 | fatalError() 29 | } 30 | } 31 | 32 | public func remoteCallVoid( 33 | on actor: Act, 34 | target: RemoteCallTarget, 35 | invocation: inout InvocationEncoder, 36 | throwing: Err.Type 37 | ) async throws 38 | where Act: DistributedActor, 39 | Act.ID == ActorID, 40 | Err: Error 41 | { 42 | _ = try await withCallIDContinuation(recipient: actor) { callID in 43 | self.sendRemoteCall(to: actor, target: target, invocation: invocation, callID: callID) 44 | } 45 | } 46 | 47 | private func withCallIDContinuation(recipient: Act, body: (CallID) -> Void) async throws -> JSValue? 48 | where Act: DistributedActor 49 | { 50 | try await withCheckedThrowingContinuation { continuation in 51 | let callID = Int.random(in: Int.min ..< Int.max) 52 | self.inFlightCalls[callID] = continuation 53 | body(callID) 54 | } 55 | } 56 | 57 | private func sendRemoteCall( 58 | to actor: Act, 59 | target: RemoteCallTarget, 60 | invocation: InvocationEncoder, 61 | callID: CallID 62 | ) 63 | where Act: DistributedActor, Act.ID == ActorID 64 | { 65 | Task { 66 | let callEnvelope = RemoteCallEnvelope( 67 | callID: callID, 68 | recipient: actor.id, 69 | invocationTarget: target.identifier, 70 | genericSubs: invocation.genericSubs, 71 | args: invocation.argumentData 72 | ) 73 | 74 | guard let childWorker = childWorkers[actor.id] else { 75 | fatalError("Invalid target") 76 | } 77 | 78 | childWorker.postMessage(.remoteCall(callEnvelope)) 79 | } 80 | } 81 | } 82 | 83 | public struct RemoteCallEnvelope: @unchecked Sendable { 84 | let callID: WebWorkerActorSystem.CallID 85 | let recipient: WebWorkerActorSystem.ActorID 86 | let invocationTarget: String 87 | let genericSubs: [String] 88 | let args: [JSValue] 89 | } 90 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerActorSystem.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | import JavaScriptEventLoop 4 | 5 | private let rawPostMessageToHost = JSObject.global.postMessage.function! 6 | private func postMessageToHost(_ message: WebWorkerMessage) { 7 | rawPostMessageToHost(message) 8 | } 9 | 10 | final public class WebWorkerActorSystem: DistributedActorSystem, Sendable { 11 | public static let thisProcessIsAWebWorker = JSObject.global.importScripts.function != nil 12 | public static let shared: WebWorkerActorSystem = .init() 13 | public static func initialize() { 14 | // Necessary to use `Task`, `await`, etc. 15 | JavaScriptEventLoop.installGlobalExecutor() 16 | 17 | _ = Self.shared // initialize the singleton 18 | } 19 | 20 | public typealias ResultHandler = WebWorkerResultHandler 21 | public typealias ActorID = WebWorkerIdentity 22 | public typealias InvocationEncoder = WebWorkerCallEncoder 23 | public typealias InvocationDecoder = WebWorkerCallDecoder 24 | public typealias SerializationRequirement = Codable 25 | 26 | public typealias CallID = Int 27 | var inFlightCalls: [CallID: CheckedContinuation] = [:] 28 | 29 | var incomingMessageClosure: JSClosure? 30 | deinit { 31 | incomingMessageClosure?.release() 32 | } 33 | 34 | init() { 35 | // This closure receives messages from the host if we are a worker, 36 | // but it also receives messages back from the worker, if we are the host! 37 | let incomingMessageClosure = JSClosure { [weak self] args -> JSValue in 38 | let event = args[0] 39 | let message: WebWorkerMessage 40 | do { 41 | message = try WebWorkerMessage(jsValue: event.data) 42 | } catch { 43 | assertionFailure("incomingMessageClosure: Unable to decode message: \(error)") 44 | return .undefined 45 | } 46 | 47 | switch message { 48 | case .processReady: 49 | guard let worker = self?.childWorkers.first(where: { $1.matchesJSObject(event.currentTarget.object) }) else { 50 | preconditionFailure("Received message from an unknown child worker!") 51 | break 52 | } 53 | 54 | worker.value.isReady = true 55 | case .remoteCall(let callEnvelope): 56 | self?.receiveInboundCall(envelope: callEnvelope) 57 | case .reply(let replyEnvelope): 58 | self?.receiveInboundReply(envelope: replyEnvelope) 59 | case .initialize(id: let id): 60 | guard let actorSystem = self else { 61 | break 62 | } 63 | 64 | id.createActor(actorSystem: actorSystem) 65 | } 66 | 67 | return .undefined 68 | } 69 | 70 | if Self.thisProcessIsAWebWorker { 71 | JSObject.global.onmessage = .object(incomingMessageClosure) 72 | } else { 73 | // We put this listener onto the WebWorkerHost. 74 | // We don't need to assign a global listener in this case. 75 | } 76 | 77 | self.incomingMessageClosure = incomingMessageClosure 78 | postMessageToHost(.processReady) 79 | } 80 | 81 | /// actors managed by the current process / address space 82 | var managedWorkers = [ActorID: any DistributedActor]() 83 | 84 | /// references to actors in child processes 85 | var childWorkers = [ActorID: WebWorkerHost]() 86 | 87 | public func actorReady(_ actor: Act) where Act: DistributedActor, ActorID == Act.ID { 88 | if managedWorkers[actor.id] != nil { 89 | fatalError("Currently only a single instance of a DistributedActor is allowed per type") 90 | } 91 | 92 | managedWorkers[actor.id] = actor 93 | 94 | // retrieve dead letter queue 95 | deadLetterQueue = deadLetterQueue.filter { envelope in 96 | let letterIsForThisActor = envelope.recipient == actor.id 97 | if letterIsForThisActor { 98 | receiveInboundCall(envelope: envelope) 99 | } 100 | 101 | return !letterIsForThisActor // remove processed messages from queue 102 | } 103 | } 104 | 105 | public func makeInvocationEncoder() -> WebWorkerCallEncoder { 106 | return WebWorkerCallEncoder() 107 | } 108 | 109 | public func resolve(id: WebWorkerIdentity, as actorType: Act.Type) throws -> Act? where Act : DistributedActor, ActorID == Act.ID { 110 | if let actor = managedWorkers[id] as? Act { 111 | return actor 112 | } 113 | 114 | if childWorkers[id] != nil { 115 | // We already have a child worker for this ID 116 | // We can continue to use it as we did before 117 | return nil 118 | } 119 | 120 | let (scriptPath, isModule) = getScriptDetails(for: Act.self) 121 | 122 | let childWorker = try WebWorkerHost(scriptPath: scriptPath, isModule: isModule) 123 | childWorker.incomingMessageClosure = incomingMessageClosure 124 | childWorker.postMessage(.initialize(id: id)) 125 | childWorkers[id] = childWorker 126 | 127 | return nil 128 | } 129 | 130 | public func assignID(_ actorType: Act.Type) -> ActorID 131 | where Act: DistributedActor, ActorID == Act.ID 132 | { 133 | return .singleton(for: actorType.self) 134 | } 135 | 136 | public func resignID(_ id: ActorID) { 137 | print("resignID: \(id)") 138 | guard let managedWorker = managedWorkers[id] else { 139 | fatalError("Tried to resign ID of an actor that doesn't exist") 140 | } 141 | 142 | // TODO: terminate 143 | // childWorkers[id] 144 | 145 | managedWorkers.removeValue(forKey: id) 146 | } 147 | 148 | func sendReply(_ envelope: ReplyEnvelope) throws { 149 | postMessageToHost(.reply(envelope)) 150 | } 151 | 152 | private var deadLetterQueue = [RemoteCallEnvelope]() 153 | func receiveInboundCall(envelope: RemoteCallEnvelope) { 154 | Task { 155 | guard let anyRecipient = managedWorkers[envelope.recipient] else { 156 | deadLetterQueue.append(envelope) 157 | return 158 | } 159 | 160 | let target = RemoteCallTarget(envelope.invocationTarget) 161 | let handler = Self.ResultHandler(callID: envelope.callID, system: self) 162 | 163 | do { 164 | var decoder = Self.InvocationDecoder(system: self, envelope: envelope) 165 | func doExecuteDistributedTarget(recipient: Act) async throws { 166 | try await executeDistributedTarget( 167 | on: recipient, 168 | target: target, 169 | invocationDecoder: &decoder, 170 | handler: handler) 171 | } 172 | 173 | // As implicit opening of existential becomes part of the language, 174 | // this underscored feature is no longer necessary. Please refer to 175 | // SE-352 Implicitly Opened Existentials: 176 | // https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md 177 | try await _openExistential(anyRecipient, do: doExecuteDistributedTarget) 178 | } catch { 179 | print("failed to executeDistributedTarget [\(target)] on [\(anyRecipient)], error: \(error)") 180 | try! await handler.onThrow(error: error) 181 | } 182 | } 183 | } 184 | 185 | func receiveInboundReply(envelope: ReplyEnvelope) { 186 | guard let callContinuation = self.inFlightCalls.removeValue(forKey: envelope.callID) else { 187 | return 188 | } 189 | 190 | callContinuation.resume(returning: envelope.value) 191 | } 192 | } 193 | 194 | private func getScriptDetails(for Act: any DistributedActor.Type) -> (scriptPath: String, isModule: Bool) { 195 | let defaultScriptPath = CommandLine.arguments.first ?? "" 196 | 197 | func getScriptInfo(recipient: Act.Type) -> (scriptPath: String, isModule: Bool) { 198 | let scriptPath = recipient.scriptPath ?? defaultScriptPath 199 | let isModule = recipient.isModule 200 | return (scriptPath, isModule) 201 | } 202 | 203 | if let Act = Act.self as? any WebWorker.Type { 204 | return _openExistential(Act, do: getScriptInfo) 205 | } else { 206 | return (defaultScriptPath, false) 207 | } 208 | } 209 | 210 | public struct ReplyEnvelope: @unchecked Sendable { 211 | let callID: WebWorkerActorSystem.CallID 212 | let sender: WebWorkerActorSystem.ActorID? 213 | let value: JSValue? 214 | } 215 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerCallDecoder.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | 4 | final public class WebWorkerCallDecoder: DistributedTargetInvocationDecoder { 5 | enum Error: Swift.Error { 6 | case notEnoughArguments(expected: Codable.Type) 7 | } 8 | 9 | public typealias SerializationRequirement = Codable 10 | 11 | let decoder: JSValueDecoder 12 | let envelope: RemoteCallEnvelope 13 | var argumentsIterator: Array.Iterator 14 | 15 | init(system: WebWorkerActorSystem, envelope: RemoteCallEnvelope) { 16 | self.envelope = envelope 17 | self.argumentsIterator = envelope.args.makeIterator() 18 | 19 | let decoder = JSValueDecoder() 20 | self.decoder = decoder 21 | } 22 | 23 | public func decodeGenericSubstitutions() throws -> [Any.Type] { 24 | envelope.genericSubs.compactMap(_typeByName) 25 | } 26 | 27 | public func decodeNextArgument() throws -> Argument { 28 | guard let data = argumentsIterator.next() else { 29 | throw Error.notEnoughArguments(expected: Argument.self) 30 | } 31 | 32 | return try decoder.decode(Argument.self, from: data) 33 | } 34 | 35 | public func decodeErrorType() throws -> Any.Type? { 36 | nil // not encoded, ok 37 | } 38 | 39 | public func decodeReturnType() throws -> Any.Type? { 40 | nil // not encoded, ok 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerCallEncoder.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | 4 | final public class WebWorkerCallEncoder: DistributedTargetInvocationEncoder, @unchecked Sendable { 5 | public typealias SerializationRequirement = Codable 6 | 7 | var genericSubs: [String] = [] 8 | var argumentData: [JSValue] = [] 9 | 10 | public func recordGenericSubstitution(_ type: T.Type) throws { 11 | if let name = _mangledTypeName(T.self) { 12 | genericSubs.append(name) 13 | } 14 | } 15 | 16 | public func recordArgument(_ argument: RemoteCallArgument) throws { 17 | let jsValue = try JSValueEncoder().encode(argument.value) 18 | self.argumentData.append(jsValue) 19 | } 20 | 21 | public func recordReturnType(_ type: R.Type) throws {} 22 | public func recordErrorType(_ type: E.Type) throws {} 23 | public func doneRecording() throws {} 24 | } 25 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerHost.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | import func WASILibc.getenv 4 | 5 | enum WebWorkerHostError: Error { 6 | case unableToLoad(scriptPath: String, isModule: Bool) 7 | } 8 | 9 | /// Handles communication between worker and host (usually the main thread, but could be a worker itself) 10 | internal class WebWorkerHost { 11 | private let jsObject: JSObject 12 | 13 | func matchesJSObject(_ otherObject: JSObject?) -> Bool { 14 | return self.jsObject == otherObject 15 | } 16 | 17 | var isReady = false { 18 | didSet { 19 | if isReady == false && oldValue == true { 20 | fatalError("Worker can become 'ready', but not 'not ready' again") 21 | } 22 | 23 | if isReady { 24 | queuedMessages.forEach { message in 25 | postMessage(message) 26 | } 27 | } 28 | } 29 | } 30 | 31 | var incomingMessageClosure: JSClosure? { 32 | didSet { 33 | jsObject.onmessage = incomingMessageClosure.map { .object($0) } ?? .undefined 34 | } 35 | } 36 | 37 | init(scriptPath: String, isModule: Bool) throws { 38 | guard let jsObject = JSObject.global.Worker.function?.new( 39 | scriptPath, 40 | isModule ? ["type": "module"] : JSValue.undefined 41 | ) else { 42 | throw WebWorkerHostError.unableToLoad( 43 | scriptPath: scriptPath, 44 | isModule: isModule 45 | ) 46 | } 47 | 48 | self.jsObject = jsObject 49 | } 50 | 51 | private var queuedMessages = [WebWorkerMessage]() 52 | func postMessage(_ message: WebWorkerMessage) { 53 | if isReady { 54 | _ = jsObject.postMessage!(message) 55 | } else { 56 | queuedMessages.append(message) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerIdentity.swift: -------------------------------------------------------------------------------- 1 | import Distributed 2 | 3 | public struct WebWorkerIdentity: Sendable, Hashable, Codable { 4 | let typeName: String 5 | 6 | private init(typeName: String) { 7 | self.typeName = typeName 8 | } 9 | 10 | internal static func singleton(for type: any DistributedActor.Type) -> WebWorkerIdentity { 11 | return WebWorkerIdentity.init(typeName: _mangledTypeName(type.self)!) 12 | } 13 | 14 | internal func createActor(actorSystem: WebWorkerActorSystem) -> (any WebWorker)? { 15 | guard let daType = _typeByName(self.typeName) as? (any WebWorker.Type) else { 16 | return nil 17 | } 18 | 19 | return daType.init(actorSystem: actorSystem) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerMessage.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | 3 | enum WebWorkerMessageError: Error { 4 | case invalidMessageType(String?) 5 | case unableToDecode(JSValue) 6 | } 7 | 8 | enum WebWorkerMessage: ConvertibleToJSValue { 9 | case processReady 10 | case remoteCall(RemoteCallEnvelope) 11 | case reply(ReplyEnvelope) 12 | case initialize(id: WebWorkerIdentity) 13 | 14 | init(jsValue data: JSValue) throws { 15 | guard let stringMessageType = data[0].string else { 16 | throw WebWorkerMessageError.invalidMessageType(nil) 17 | } 18 | 19 | switch stringMessageType { 20 | case "initialize": 21 | let id = try JSValueDecoder().decode(WebWorkerIdentity.self, from: data[1]) 22 | self = .initialize(id: id) 23 | case "remoteCall": 24 | let decoder = JSValueDecoder() 25 | guard 26 | let callID = data[1].callID.number, 27 | let invocationTarget = data[1].invocationTarget.string, 28 | let args = JSArray(from: data[1].args) 29 | else { 30 | throw WebWorkerMessageError.unableToDecode(data[1]) 31 | } 32 | 33 | let genericSubs = try decoder.decode([String].self, from: data[1].genericSubs) 34 | let recipient = try decoder.decode(WebWorkerActorSystem.ActorID.self, from: data[1].recipient) 35 | 36 | let remoteCallEnvelope = RemoteCallEnvelope( 37 | callID: WebWorkerActorSystem.CallID(callID), 38 | recipient: recipient, 39 | invocationTarget: invocationTarget, 40 | genericSubs: genericSubs, 41 | args: args.map { $0 } 42 | ) 43 | 44 | self = .remoteCall(remoteCallEnvelope) 45 | case "reply": 46 | guard let callID = data[1].callID.number else { 47 | throw WebWorkerMessageError.unableToDecode(data[1]) 48 | } 49 | 50 | let decoder = JSValueDecoder() 51 | let replyEnvelope = ReplyEnvelope( 52 | callID: WebWorkerActorSystem.CallID(callID), 53 | sender: try? decoder.decode(WebWorkerIdentity.self, from: data[1].sender), 54 | value: data[1].value 55 | ) 56 | 57 | self = .reply(replyEnvelope) 58 | case "processReady": 59 | self = .processReady 60 | default: 61 | throw WebWorkerMessageError.invalidMessageType(stringMessageType) 62 | } 63 | } 64 | 65 | var jsValue: JSValue { 66 | let encoder = JSValueEncoder() 67 | switch self { 68 | case .remoteCall(let callEnvelope): 69 | let recipient = try? encoder.encode(callEnvelope.recipient) 70 | let callEnvelope = [ 71 | "callID": callEnvelope.callID, 72 | "genericSubs": callEnvelope.genericSubs, 73 | "invocationTarget": callEnvelope.invocationTarget, 74 | "args": callEnvelope.args, 75 | "recipient": recipient 76 | ].jsValue 77 | 78 | return ["remoteCall", callEnvelope].jsValue 79 | 80 | case .processReady: 81 | return ["processReady"].jsValue 82 | 83 | case .reply(let payload): 84 | let sender = try? encoder.encode(payload.sender) 85 | let replyEnvelope = [ 86 | "callID": payload.callID, 87 | "sender": sender, 88 | "value": payload.value 89 | ].jsValue 90 | 91 | return ["reply", replyEnvelope].jsValue 92 | 93 | case .initialize(id: let payload): 94 | let id = try! encoder.encode(payload) 95 | return ["initialize", id].jsValue 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/WebWorkerKit/WebWorkerResultHandler.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Distributed 3 | 4 | public struct WebWorkerResultHandler: DistributedTargetInvocationResultHandler { 5 | public typealias SerializationRequirement = Codable 6 | 7 | let callID: WebWorkerActorSystem.CallID 8 | let system: WebWorkerActorSystem 9 | 10 | public func onReturn(value: Success) async throws { 11 | let encoded = try JSValueEncoder().encode(value) 12 | let envelope = ReplyEnvelope(callID: self.callID, sender: nil, value: encoded) 13 | try system.sendReply(envelope) 14 | } 15 | 16 | public func onReturnVoid() async throws { 17 | let envelope = ReplyEnvelope(callID: self.callID, sender: nil, value: nil) 18 | try system.sendReply(envelope) 19 | } 20 | 21 | public func onThrow(error: Err) async throws { 22 | print("onThrow: \(error)") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/WebWorkerKitTests/WebWorkerKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WebWorkerKit 3 | 4 | // TODO 5 | 6 | final class WebWorkerKitTests: XCTestCase { 7 | func testExample() throws { 8 | // This is an example of a functional test case. 9 | // Use XCTAssert and related functions to verify your tests produce the correct 10 | // results. 11 | XCTAssertEqual(WebWorkerKit().text, "Hello, World!") 12 | } 13 | } 14 | --------------------------------------------------------------------------------