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