1 | | | import 'dart:async'; |
2 | | | import 'dart:html' as web; |
3 | | |
|
4 | | | import '../cancellation_token.dart'; |
5 | | | import '../channel.dart' show Channel, WorkerChannel; |
6 | | | import '../squadron.dart'; |
7 | | | import '../squadron_exception.dart'; |
8 | | | import '../worker_exception.dart'; |
9 | | | import '../worker_request.dart'; |
10 | | | import '../worker_response.dart'; |
11 | | |
|
12 | | | class _MessagePort { |
13 | | | /// [web.MessagePort] to communicate with the [web.Worker] if the channel is owned by the worker owner. |
14 | | | /// Otherwise, [web.MessagePort] to return values to the client. |
15 | | | web.MessagePort? _sendPort; |
16 | | |
|
17 | | | /// [Channel] serialization in JavaScript world returns the [web.MessagePort]. |
18 | | 1 | dynamic serialize() => _sendPort; |
19 | | |
|
20 | | 1 | void _postRequest(WorkerRequest req) { |
21 | | 1 | final message = req.serialize(); |
22 | | 1 | try { |
23 | | 1 | final transfer = _getTransferables(message).toList(); |
24 | | 1 | _sendPort!.postMessage(message, transfer); |
25 | | | } catch (ex) { |
26 | | 1 | Squadron.severe('failed to post request $message: error $ex'); |
27 | | 1 | rethrow; |
28 | | | } |
29 | | 1 | } |
30 | | |
|
31 | | 1 | void _postResponse(WorkerResponse res) { |
32 | | 1 | final message = res.serialize(); |
33 | | 0 | try { |
34 | | 1 | final transfer = _getTransferables(message).toList(); |
35 | | 1 | _sendPort!.postMessage(message, transfer); |
36 | | | } catch (ex) { |
37 | | 0 | Squadron.severe('failed to post response $message: error $ex'); |
38 | | 0 | rethrow; |
39 | | | } |
40 | | 1 | } |
41 | | | } |
42 | | |
|
43 | | | /// [Channel] implementation for the JavaScript world. |
44 | | | class _JsChannel extends _MessagePort implements Channel { |
45 | | 1 | _JsChannel._(); |
46 | | |
|
47 | | | /// [Channel] sharing in JavaScript world returns a [_JsForwardChannel]. |
48 | | | @override |
49 | | 1 | Channel share() => _JsForwardChannel._(_sendPort!); |
50 | | |
|
51 | | | /// Sends a termination [WorkerRequest] to the [web.Worker] and clears the [web.MessagePort]. |
52 | | | @override |
53 | | 1 | FutureOr close() { |
54 | | 1 | if (_sendPort != null) { |
55 | | 1 | _postRequest(WorkerRequest.stop()); |
56 | | 1 | _sendPort = null; |
57 | | | } |
58 | | 1 | } |
59 | | |
|
60 | | | /// If the [token] is cancelled, sends a [WorkerRequest.cancel] message to signal the worker that the token is |
61 | | | /// cancelled. |
62 | | | @override |
63 | | | void notifyCancellation(CancellationToken token) { |
64 | | | if (token.cancelled) { |
65 | | 1 | _postRequest(WorkerRequest.cancel(token)); |
66 | | | } |
67 | | | } |
68 | | |
|
69 | | | /// Creates a [web.MessageChannel] and a [WorkerRequest] and sends it to the [web.Worker]. |
70 | | | /// This method expects a single value from the [web.Worker]. |
71 | | | @override |
72 | | 1 | Future<T> sendRequest<T>(int command, List args, |
73 | | | {CancellationToken? token}) async { |
74 | | 1 | final com = web.MessageChannel(); |
75 | | | try { |
76 | | 1 | _postRequest(WorkerRequest(com.port2, command, args, token)); |
77 | | 1 | final event = await com.port1.onMessage.first; |
78 | | 1 | final res = WorkerResponse.deserialize(event.data); |
79 | | 1 | return res.result as T; |
80 | | | } finally { |
81 | | 1 | com.port2.close(); |
82 | | 1 | com.port1.close(); |
83 | | | } |
84 | | 1 | } |
85 | | |
|
86 | | | /// Creates a [web.MessageChannel] and a [WorkerRequest] and sends it to the [web.Worker]. |
87 | | | /// This method expects a stream of values from the [web.Worker]. |
88 | | | /// The [web.Worker] must send a [WorkerResponse.endOfStream] to close the [Stream]. |
89 | | | @override |
90 | | 1 | Stream<T> sendStreamingRequest<T>(int command, List args, |
91 | | | {CancellationToken? token}) { |
92 | | 1 | final controller = StreamController<T>(); |
93 | | | final com = web.MessageChannel(); |
94 | | 1 | com.port1.onMessage.listen((event) { |
95 | | 1 | final res = WorkerResponse.deserialize(event.data); |
96 | | | if (res.endOfStream) { |
97 | | 1 | controller.close(); |
98 | | 1 | com.port2.close(); |
99 | | 1 | com.port1.close(); |
100 | | 1 | } else if (res.hasError) { |
101 | | 1 | controller.addError(res.error!, res.error!.stackTrace); |
102 | | 1 | controller.close(); |
103 | | 1 | com.port2.close(); |
104 | | 1 | com.port1.close(); |
105 | | | } else { |
106 | | 1 | controller.add(res.result); |
107 | | | } |
108 | | 1 | }); |
109 | | 1 | _postRequest(WorkerRequest(com.port2, command, args, token)); |
110 | | 1 | return controller.stream; |
111 | | 1 | } |
112 | | | } |
113 | | |
|
114 | | | /// [WorkerChannel] implementation for the JavaScript world. |
115 | | | class _JsWorkerChannel extends _MessagePort implements WorkerChannel { |
116 | | 1 | _JsWorkerChannel._(); |
117 | | |
|
118 | | | /// Sends the [web.MessagePort] to communicate with the [web.Worker]. |
119 | | | /// This method must be called by the [web.Worker] upon startup. |
120 | | | @override |
121 | | | void connect(Object channelInfo) { |
122 | | | if (channelInfo is web.MessagePort) { |
123 | | | reply(channelInfo); |
124 | | | } else { |
125 | | | throw WorkerException( |
126 | | | 'invalid channelInfo ${channelInfo.runtimeType}: MessagePort expected'); |
127 | | | } |
128 | | | } |
129 | | |
|
130 | | | /// Sends a [WorkerResponse] with the specified data to the worker client. |
131 | | | /// This method must be called from the [web.Worker] only. |
132 | | | @override |
133 | | 1 | void reply(dynamic data) => _postResponse(WorkerResponse(data)); |
134 | | |
|
135 | | | /// Sends a [WorkerResponse.closeStream] to the worker client. |
136 | | | /// This method must be called from the [web.Worker] only. |
137 | | | @override |
138 | | 1 | void closeStream() => _postResponse(WorkerResponse.closeStream); |
139 | | |
|
140 | | | /// Sends the [WorkerResponse] to the worker client. |
141 | | | /// This method must be called from the [web.Worker] only. |
142 | | | @override |
143 | | 1 | void error(SquadronException error) { |
144 | | 1 | Squadron.finer(() => 'replying with error: $error'); |
145 | | 1 | _postResponse(WorkerResponse.withError(error)); |
146 | | 1 | } |
147 | | | } |
148 | | |
|
149 | | | /// [Channel] used to communicate between [web.Worker]s. |
150 | | | /// Creates a [web.MessageChannel] to receive commands on [web.MessageChannel.port2] and forwards them |
151 | | | /// to the worker's [web.MessagePort] via [web.MessageChannel.port1]. |
152 | | | class _JsForwardChannel extends _JsChannel { |
153 | | | /// [remote] is the worker's [web.MessagePort] |
154 | | 1 | _JsForwardChannel._(web.MessagePort remote) : super._() { |
155 | | 1 | _remote = remote; |
156 | | 1 | _com.port1.onMessage.listen(_forward); |
157 | | 1 | _sendPort = _com.port2; |
158 | | 1 | } |
159 | | |
|
160 | | | /// [web.MessagePort] to the worker. |
161 | | | web.MessagePort? _remote; |
162 | | |
|
163 | | | /// [web.MessageChannel] used for forwarding messages. |
164 | | | final _com = web.MessageChannel(); |
165 | | |
|
166 | | | /// Forwards [web.MessageEvent.data] to the worker. |
167 | | 1 | void _forward(web.MessageEvent e) { |
168 | | 1 | final message = e.data; |
169 | | 0 | try { |
170 | | 1 | final transfer = _getTransferables(message).toList(); |
171 | | 1 | _remote!.postMessage(message, transfer); |
172 | | | } catch (ex) { |
173 | | 0 | Squadron.severe('failed to forward $message: error $ex'); |
174 | | 0 | rethrow; |
175 | | | } |
176 | | 1 | } |
177 | | |
|
178 | | | /// Closes this [Channel], effectively stopping message forwarding. |
179 | | | @override |
180 | | 0 | void close() { |
181 | | 0 | _remote = null; |
182 | | 0 | _com.port1.close(); |
183 | | 0 | } |
184 | | | } |
185 | | |
|
186 | | | /// Checks if [value] is a base type value or an object. |
187 | | 1 | bool _isObject(dynamic value) => |
188 | | 1 | value != null && |
189 | | | value is! num && |
190 | | | value is! bool && |
191 | | | value is! String && |
192 | | 1 | value is! List<num> && |
193 | | 1 | value is! List<bool> && |
194 | | 1 | value is! List<String>; |
195 | | |
|
196 | | | /// Excludes base type values from [list]. |
197 | | 1 | Iterable<Object> _getObjects(Iterable list, Set<Object> seen) sync* { |
198 | | 1 | for (var o in list.where(_isObject)) { |
199 | | 1 | if (!seen.contains(o)) { |
200 | | 1 | seen.add(o); |
201 | | 1 | yield o as Object; |
202 | | | } |
203 | | | } |
204 | | 1 | } |
205 | | |
|
206 | | | /// Yields objects contained in JSON object [args] (a Map, a List, or a base type). |
207 | | | /// Used to identify non-base type objects and provide them to [web.Worker.postMessage]. |
208 | | | /// [web.Worker.postMessage] will clone these objects -- essentially [web.MessagePort]s. |
209 | | | /// The code makes no effort to ensure these objects really are transferable. |
210 | | 1 | Iterable<Object> _getTransferables(dynamic args) sync* { |
211 | | 1 | if (_isObject(args)) { |
212 | | 1 | if (args is Map) args = args.values; |
213 | | 1 | if (args is! Iterable) { |
214 | | 0 | yield args as Object; |
215 | | | } else { |
216 | | 1 | final seen = <Object>{}; |
217 | | 1 | final toBeInspected = <Object>[]; |
218 | | 1 | toBeInspected.addAll(_getObjects(args, seen)); |
219 | | 1 | var i = 0; |
220 | | 1 | while (i < toBeInspected.length) { |
221 | | | final arg = toBeInspected[i++]; |
222 | | 1 | if (arg is Map) { |
223 | | 1 | toBeInspected.addAll(_getObjects(arg.values, seen)); |
224 | | 1 | } else if (arg is Iterable) { |
225 | | 1 | toBeInspected.addAll(_getObjects(arg, seen)); |
226 | | | } else { |
227 | | 1 | yield arg; |
228 | | | } |
229 | | | } |
230 | | | } |
231 | | | } |
232 | | 1 | } |
233 | | |
|
234 | | | /// Stub implementations |
235 | | |
|
236 | | | int _counter = 0; |
237 | | | String _getId() { |
238 | | 1 | _counter++; |
239 | | | return '${Squadron.id}.$_counter'; |
240 | | | } |
241 | | |
|
242 | | | /// Starts a [web.Worker] using the [entryPoint] and sends a start [WorkerRequest] with [startArguments]. |
243 | | | /// The future completes after the [web.Worker]'s main program has provided the [web.MessagePort] via [_JsWorkerChannel.connect]. |
244 | | 1 | Future<Channel> openChannel(dynamic entryPoint, List startArguments) { |
245 | | | final completer = Completer<Channel>(); |
246 | | | final channel = _JsChannel._(); |
247 | | | final com = web.MessageChannel(); |
248 | | 1 | final worker = web.Worker(entryPoint); |
249 | | | Squadron.config('created Web Worker #${worker.hashCode}'); |
250 | | 1 | worker.onError.listen((event) { |
251 | | | String msg; |
252 | | 0 | if (event is web.ErrorEvent) { |
253 | | | final error = event; |
254 | | | msg = |
255 | | 0 | '$entryPoint => ${error.message} [${error.filename}(${error.lineno})]'; |
256 | | | } else { |
257 | | 0 | msg = '$entryPoint: ${event.type} / $event'; |
258 | | | } |
259 | | 0 | Squadron.severe('error in Web Worker #${worker.hashCode}: $msg'); |
260 | | 0 | if (!completer.isCompleted) { |
261 | | 0 | completer.completeError( |
262 | | | WorkerException('error in Web Worker #${worker.hashCode}: $msg')); |
263 | | 0 | worker.terminate(); |
264 | | | } |
265 | | 0 | }); |
266 | | | final message = |
267 | | 1 | WorkerRequest.start(com.port2, _getId(), startArguments).serialize(); |
268 | | 0 | try { |
269 | | 1 | final transfer = _getTransferables(message).toList(); |
270 | | 1 | worker.postMessage(message, transfer); |
271 | | | } catch (ex) { |
272 | | 0 | com.port1.close(); |
273 | | 0 | worker.terminate(); |
274 | | 0 | Squadron.severe('failed to post connection request $message: error $ex'); |
275 | | 0 | rethrow; |
276 | | | } |
277 | | 1 | com.port1.onMessage.listen((event) { |
278 | | 1 | com.port1.close(); |
279 | | 1 | final response = WorkerResponse.deserialize(event.data); |
280 | | | SquadronException? error = response.error; |
281 | | 1 | if (error == null) { |
282 | | 0 | try { |
283 | | 1 | channel._sendPort = response.result; |
284 | | 1 | Squadron.config('connected to Web Worker #${worker.hashCode}'); |
285 | | 1 | completer.complete(channel); |
286 | | 0 | } catch (ex, st) { |
287 | | 0 | error = SquadronException.from(error: ex, stackTrace: st); |
288 | | | } |
289 | | | } |
290 | | 1 | if (error != null) { |
291 | | 1 | worker.terminate(); |
292 | | | Squadron.severe( |
293 | | | 'connection to Web Worker #${worker.hashCode} failed: ${response.error}'); |
294 | | 1 | completer.completeError(error, error.stackTrace); |
295 | | | } |
296 | | 1 | }); |
297 | | 1 | return completer.future; |
298 | | 1 | } |
299 | | |
|
300 | | | /// Creates a [_JsChannel] from a [web.MessagePort]. |
301 | | | Channel? deserializeChannel(dynamic channelInfo) => |
302 | | 1 | (channelInfo == null) ? null : (_JsChannel._().._sendPort = channelInfo); |
303 | | |
|
304 | | | /// Creates a [_JsWorkerChannel] from a [web.MessagePort]. |
305 | | 1 | WorkerChannel? deserializeWorkerChannel(dynamic channelInfo) => |
306 | | 1 | (channelInfo == null) |
307 | | | ? null |
308 | | 1 | : (_JsWorkerChannel._().._sendPort = channelInfo); |