web_thread/lib.rs
1/*!
2# `web-thread`
3
4A crate for long-running, shared-memory threads in a browser context
5for use with
6[`wasm-bindgen`](https://github.com/wasm-bindgen/wasm-bindgen).
7Supports sending non-`Send` data across the boundary using
8`postMessage` and
9[transfer](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects).
10
11## Requirements
12
13Like all Web threading solutions, this crate requires Wasm atomics,
14bulk memory, and mutable globals:
15
16`.cargo/config.toml`
17
18```toml
19[target.wasm32-unknown-unknown]
20rustflags = [
21 "-C", "target-feature=+atomics,+bulk-memory,+mutable-globals",
22]
23```
24
25as well as cross-origin isolation on the serving Web page in order to
26[enable the use of
27`SharedArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements),
28i.e. the HTTP headers
29
30```text
31Cross-Origin-Opener-Policy: same-origin
32Cross-Origin-Embedder-Policy: require-corp
33```
34
35The `credentialless` value for `Cross-Origin-Embedder-Policy` should
36also work, but at the time of writing is not supported in Safari.
37
38## Linking the binary
39
40Since this crate can't know the location of your shim script and Wasm
41binary ahead of time, you must make the module identifier
42`web-thread:wasm-shim` resolve to the path of your `wasm-bindgen` shim
43script. This can be done with a bundler such as
44[Vite](https://vite.dev/) or [Webpack](https://webpack.js.org/), or by
45using a source-transformation tool such as
46[`tsc-alias`](https://www.npmjs.com/package/tsc-alias?activeTab=readme):
47
48`tsconfig.json`
49
50```json
51{
52 "compilerOptions": {
53 "baseUrl": "./",
54 "paths": {
55 "web-thread:wasm-shim": ["./src/wasm/my-library.js"]
56 }
57 },
58 "tsc-alias": {
59 "resolveFullPaths": true
60 }
61}
62```
63
64Turbopack is currently not supported due to an open issue when
65processing cyclic dependencies. See the following discussions for
66more information:
67
68* [Turbopack: dynamic cyclical import causes infinite loop (#85119)](https://github.com/vercel/next.js/issues/85119)
69* [Next.js v15.2.2 Turbopack Dev server stuck in compiling + extreme CPU/memory usage (#77102)](https://github.com/vercel/next.js/discussions/77102)
70* [Eliminate the circular dependency between the main loader and the worker (#20580)](https://github.com/emscripten-core/emscripten/issues/20580)
71
72*/
73
74mod error;
75
76mod post;
77use std::{
78 pin::Pin,
79 task::{Context, Poll, ready},
80};
81
82use futures::{FutureExt as _, TryFutureExt as _, channel::oneshot, future};
83use post::Postable;
84pub use post::{AsJs, Post, PostExt};
85use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
86use wasm_bindgen_futures::JsFuture;
87use web_sys::{js_sys, wasm_bindgen};
88
89pub type Result<T, E = Error> = std::result::Result<T, E>;
90
91#[wasm_bindgen(module = "/src/Client.js")]
92extern "C" {
93 // We would like to give this a better name with `js_name`, but `js_name`
94 #[wasm_bindgen(js_name = "web_thread$Client")]
95 type Client;
96 #[wasm_bindgen(constructor, js_class = "web_thread$Client")]
97 fn new(module: JsValue, memory: JsValue) -> Client;
98
99 #[wasm_bindgen(js_class = "web_thread$Client", method)]
100 fn run(
101 this: &Client,
102 code: JsValue,
103 context: JsValue,
104 transfer: js_sys::Array,
105 ) -> js_sys::Promise;
106
107 #[wasm_bindgen(js_class = "web_thread$Client", method)]
108 fn destroy(this: &Client);
109}
110
111/// A representation of a JavaScript thread (Web worker with shared memory).
112pub struct Thread(Client);
113
114pin_project_lite::pin_project! {
115 /// A task that's been spawned on a [`Thread`].
116 ///
117 /// Dropping the thread before the task is complete will result in the
118 /// task erroring.
119 pub struct Task<T> {
120 result: future::Either<
121 future::MapErr<JsFuture, fn(JsValue) -> Error>,
122 future::Ready<Result<JsValue>>,
123 >,
124 _phantom: std::marker::PhantomData<T>,
125 }
126}
127
128impl<T: Post> Future for Task<T> {
129 type Output = Result<T>;
130
131 fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
132 Poll::Ready(Ok(T::from_js(ready!(self.result.poll_unpin(context))?)?))
133 }
134}
135
136pin_project_lite::pin_project! {
137 /// A [`Task`] with a `Send` output.
138 /// See [`Task::run_send`] for usage.
139 pub struct SendTask<T> {
140 task: Task<()>,
141 receiver: oneshot::Receiver<T>,
142 }
143}
144
145impl<T: Send> Future for SendTask<T> {
146 type Output = Result<T>;
147
148 fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
149 ready!(self.task.poll_unpin(context))?;
150 Poll::Ready(Ok(
151 ready!(self.receiver.poll_unpin(context)).expect("task already completed successfully")
152 ))
153 }
154}
155
156impl Thread {
157 /// Spawn a new thread.
158 #[must_use]
159 pub fn new() -> Self {
160 Self(Client::new(wasm_bindgen::module(), wasm_bindgen::memory()))
161 }
162
163 /// Execute a function on a thread.
164 ///
165 /// The function will begin executing immediately. The resulting
166 /// [`Task`] can be awaited to retrieve the result.
167 ///
168 /// # Arguments
169 ///
170 /// ## `context`
171 ///
172 /// A [`Post`]able context that will be sent across the thread
173 /// boundary using `postMessage` and passed to the function on the
174 /// other side.
175 ///
176 /// ## `code`
177 ///
178 /// A `FnOnce` implementation containing the code in question.
179 /// The function is async, but will run on a `Worker` so may block
180 /// (though doing so will block the thread!). The function itself
181 /// must be `Send`, and `Send` values can be sent through in its
182 /// closure, but once executed the resulting [`Future`] will not
183 /// be moved, so needn't be `Send`.
184 pub fn run<Context: Post, F: Future<Output: Post> + 'static>(
185 &self,
186 context: Context,
187 code: impl FnOnce(Context) -> F + Send + 'static,
188 ) -> Task<F::Output> {
189 // While not syntactically consumed, the use of `postMessage`
190 // here may leave `Context` in an invalid state (setting
191 // transferred JavaScript values to `undefined`).
192 #![allow(clippy::needless_pass_by_value)]
193
194 let transfer = context.transferables();
195 Task {
196 _phantom: std::marker::PhantomData,
197 result: match context.to_js() {
198 Ok(context) => future::Either::Left(
199 JsFuture::from(self.0.run(Code::new(code).into(), context, transfer))
200 .map_err(Into::into),
201 ),
202 Err(error) => future::Either::Right(future::ready(Err(error.into()))),
203 },
204 }
205 }
206
207 /// Like [`Thread::run`], but the output can be sent through Rust
208 /// memory without `Post`ing.
209 pub fn run_send<Context: Post, F: Future<Output: Send> + 'static>(
210 &self,
211 context: Context,
212 code: impl FnOnce(Context) -> F + Send + 'static,
213 ) -> SendTask<F::Output> {
214 let (sender, receiver) = oneshot::channel();
215 SendTask {
216 task: self.run(context, |context| {
217 code(context).map(|outcome| {
218 let _ = sender.send(outcome);
219 })
220 }),
221 receiver,
222 }
223 }
224}
225
226impl Default for Thread {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232impl Drop for Thread {
233 fn drop(&mut self) {
234 self.0.destroy();
235 }
236}
237
238/// The type of errors that can be thrown in the course of executing a thread.
239pub type Error = error::Error;
240
241type JsTask = std::pin::Pin<Box<dyn Future<Output = Result<Postable, JsValue>>>>;
242type RemoteTask = Box<dyn FnOnce(JsValue) -> JsTask + Send>;
243
244struct Code {
245 // The second box allows us to represent this as a thin pointer
246 // (Wasm: u32) which, unlike fat pointers (Wasm: u64) is within
247 // the [JavaScript safe integer
248 // range](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger).
249 code: Option<Box<RemoteTask>>,
250}
251
252impl Code {
253 fn new<F: Future<Output: Post> + 'static, Context: Post>(
254 code: impl FnOnce(Context) -> F + Send + 'static,
255 ) -> Self {
256 Self {
257 code: Some(Box::new(Box::new(|context| {
258 Box::pin(async move { Postable::new(code(Context::from_js(context)?).await) })
259 }))),
260 }
261 }
262
263 async fn call_once(mut self, context: JsValue) -> Result<Postable, JsValue> {
264 (*self.code.take().expect("code called more than once"))(context).await
265 }
266
267 /// # Safety
268 ///
269 /// Must only be called on `JsValue`s created with the
270 /// `Into<JsValue>` implementation.
271 unsafe fn from_js_value(js_value: &JsValue) -> Self {
272 // We know this doesn't truncate or lose sign as the `f64` is
273 // a representation of a 32-bit pointer.
274 #![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
275
276 Self {
277 code: Some(unsafe { Box::from_raw(js_value.as_f64().unwrap() as u32 as _) }),
278 }
279 }
280}
281
282impl From<Code> for JsValue {
283 fn from(code: Code) -> Self {
284 (Box::into_raw(code.code.expect("serializing consumed code")) as u32).into()
285 }
286}
287
288#[doc(hidden)]
289#[wasm_bindgen]
290pub async unsafe fn __web_thread_worker_entry_point(
291 code: JsValue,
292 context: JsValue,
293) -> Result<JsValue, JsValue> {
294 let code = unsafe { Code::from_js_value(&code) };
295 serde_wasm_bindgen::to_value(&code.call_once(context).await?).map_err(Into::into)
296}
297
298#[wasm_bindgen(module = "/src/worker.js")]
299extern "C" {
300 // This is here just to ensure `/src/worker.js` makes it into the
301 // bundle produced by `wasm-bindgen`.
302 fn _non_existent_function();
303}