//! The Slides core: the deck's Yjs CRDT, compiled to wasm and run under //! wazero on the server. It owns four things — the deck schema over yrs //! (`schema`), the operations the drive surface applies (`ops`), the //! JSON view the server reads a deck through (`view`), and the //! accepted-shape check the server gates a posted update on (`check`) — //! plus the Markdown subset the operations accept text in (`markdown`). //! //! # Important: Doc construction //! //! Never call `yrs::Doc::new()` *or* `yrs::Doc::with_client_id()` in this //! crate. Both draw entropy from `fastrand`, not just `Doc::new()`: //! `Doc::new()` picks a random client id *and* GUID; `Doc::with_client_id()` //! takes the client id explicitly but still draws a random GUID internally //! (`Options::with_client_id`, `yrs` `src/doc.rs:916`, calls `uuid_v4()` //! regardless). The only construction that supplies everything itself, and //! so is safe to use, is `Doc::with_options(Options::with_guid_and_client_id //! (guid, client_id))` (`doc.rs:928`) with both values we choose. This is //! enforced by `clippy.toml` (`disallowed-methods`), wired into //! `scripts/build-wasm.sh` — not just documented here, because this rule //! has been gotten wrong more than once already in Docs (see below). //! //! The same rule reaches further here than in Docs: every slide and block //! id arrives in the op JSON from the Go host (`id`, `new_id`, //! `new_block_ids`). The core never draws entropy for anything, and an op //! that needs an id and lacks one is an error, not an id. //! //! # Zero host imports and the custom getrandom backend //! //! On `wasm32-unknown-unknown`, `yrs`'s pinned `fastrand` dependency //! requires the `js` feature, which makes the transitively-pulled //! `getrandom` crate default to a `wasm-bindgen`-based backend that imports //! host JS functions. Merely referencing `yrs::Doc` anywhere in this crate //! is enough for those `wasm-bindgen` exports (`__wbindgen_describe`, //! `__wbindgen_malloc`, ...) to end up linked into the final module, even //! along code paths that are never reached at runtime — which breaks the //! zero-imports contract with the Go/wazero host (see //! `core/tests/no_host_imports.rs`). //! //! `.cargo/config.toml` (repository root) selects getrandom's "custom" //! backend for the `wasm32-unknown-unknown` target only (native builds, //! including `cargo test`, are unaffected and keep the real OS entropy //! source). That backend calls out to the `__getrandom_v03_custom` symbol //! defined below instead of any JS API. It refuses the request (`Err`) //! rather than fabricating bytes — but that refusal is weaker than it //! sounds, and the next paragraph is the actual reason construction must //! never draw a GUID or client id from the library, not "so a panic tells //! us": //! //! `fastrand::Rng::new()` — what both `Observer::subscribe()` //! (`yrs` `src/observer.rs:128`/`:145`, subscription ids) and `uuid_v4()` //! (`yrs` `src/lib.rs:713`, document GUIDs) call — does not propagate our //! `Err`. It masks it: `vendor/fastrand-2.5.0/src/global_rng.rs:223` turns //! any `getrandom` failure into `None` and falls back to a constant seed. //! `Rng::new()` itself then *forks* the thread-local generator from that //! seed and advances it, so successive calls within one process still //! produce different values — subscription ids don't collide with each //! other, and this crate's own tests can't see anything wrong. The actual //! hazard is cross-process: the thread-local seed is the same fixed //! constant on every process, every time, so the *sequence* of "random" //! ids/GUIDs a process draws is identical across processes. Two //! independently-created documents that never draw a client id or GUID //! explicitly would collide. This crate avoids the whole problem by //! supplying both explicitly (see above) rather than relying on `fastrand` //! to fail loudly when it can't — it won't. pub mod abi; pub mod check; mod layout; pub mod markdown; mod ops; mod schema; mod view; pub use check::check; pub use ops::apply_ops; pub use schema::{Paragraph, Run}; pub use view::view_json; use yrs::updates::decoder::Decode; use yrs::{Doc, Options, ReadTxn, StateVector, Transact, Update}; #[cfg(target_arch = "wasm32")] #[no_mangle] extern "Rust" fn __getrandom_v03_custom( _dest: *mut u8, _len: usize, ) -> Result<(), getrandom::Error> { Err(getrandom::Error::UNSUPPORTED) } /// Returns the ABI version this module exports. The Go host checks this on /// load and refuses a module it does not understand, so a stale .wasm on a /// box fails loudly instead of behaving strangely. /// /// Version 1 was the surface `abi.rs` exported at slice 1: doc lifecycle, /// apply, the state/json read pairs, and ops with its out/err pairs. /// Version 2 adds `slides_core_check` (codex 3), the accepted-shape /// check the host runs on a posted update before storing it. An export /// added forces a bump even though it is, strictly, backwards compatible /// on the wasm side: the module and the Go binary ship embedded in each /// other, and `internal/core`'s check is exact-match, so the bump is what /// turns "unknown export" deep inside a request into a refusal at load /// time. #[no_mangle] pub extern "C" fn slides_core_abi_version() -> u32 { 2 } #[derive(Debug)] pub enum CoreError { /// The bytes were not a valid Yjs v1 update. Decode(String), /// The update decoded but could not be applied. Apply(String), /// The operation batch was refused; nothing was applied. The message /// is what the host surfaces to the caller. Ops(String), } impl std::fmt::Display for CoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CoreError::Decode(m) => write!(f, "decode: {m}"), CoreError::Apply(m) => write!(f, "apply: {m}"), CoreError::Ops(m) => write!(f, "ops: {m}"), } } } impl std::error::Error for CoreError {} /// A document with the caller's GUID and client id. The one sanctioned /// constructor (see the module comment): nothing here draws entropy. pub fn new_doc(guid: &str, client_id: u64) -> Doc { Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id)) } /// Applies a Yjs v1 update to `doc`. pub fn apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError> { let update = Update::decode_v1(update).map_err(|e| CoreError::Decode(e.to_string()))?; let mut txn = doc.transact_mut(); txn.apply_update(update) .map_err(|e| CoreError::Apply(e.to_string())) } /// The whole document as one v1 update: `encode_state_as_update_v1` /// against the EMPTY state vector, which is the call that carries every /// block, the whole delete set and the pending queue (Docs' /// `snapshot.rs` explains why the `encode_diff_v1` neighbour is a trap). pub fn state_bytes(doc: &Doc) -> Vec { doc.transact() .encode_state_as_update_v1(&StateVector::default()) }