oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1//! The Slides core: the deck's Yjs CRDT, compiled to wasm and run under
2//! wazero on the server. It owns four things — the deck schema over yrs
3//! (`schema`), the operations the drive surface applies (`ops`), the
4//! JSON view the server reads a deck through (`view`), and the
5//! accepted-shape check the server gates a posted update on (`check`) —
6//! plus the Markdown subset the operations accept text in (`markdown`).
7//!
8//! # Important: Doc construction
9//!
10//! Never call `yrs::Doc::new()` *or* `yrs::Doc::with_client_id()` in this
11//! crate. Both draw entropy from `fastrand`, not just `Doc::new()`:
12//! `Doc::new()` picks a random client id *and* GUID; `Doc::with_client_id()`
13//! takes the client id explicitly but still draws a random GUID internally
14//! (`Options::with_client_id`, `yrs` `src/doc.rs:916`, calls `uuid_v4()`
15//! regardless). The only construction that supplies everything itself, and
16//! so is safe to use, is `Doc::with_options(Options::with_guid_and_client_id
17//! (guid, client_id))` (`doc.rs:928`) with both values we choose. This is
18//! enforced by `clippy.toml` (`disallowed-methods`), wired into
19//! `scripts/build-wasm.sh` — not just documented here, because this rule
20//! has been gotten wrong more than once already in Docs (see below).
21//!
22//! The same rule reaches further here than in Docs: every slide and block
23//! id arrives in the op JSON from the Go host (`id`, `new_id`,
24//! `new_block_ids`). The core never draws entropy for anything, and an op
25//! that needs an id and lacks one is an error, not an id.
26//!
27//! # Zero host imports and the custom getrandom backend
28//!
29//! On `wasm32-unknown-unknown`, `yrs`'s pinned `fastrand` dependency
30//! requires the `js` feature, which makes the transitively-pulled
31//! `getrandom` crate default to a `wasm-bindgen`-based backend that imports
32//! host JS functions. Merely referencing `yrs::Doc` anywhere in this crate
33//! is enough for those `wasm-bindgen` exports (`__wbindgen_describe`,
34//! `__wbindgen_malloc`, ...) to end up linked into the final module, even
35//! along code paths that are never reached at runtime — which breaks the
36//! zero-imports contract with the Go/wazero host (see
37//! `core/tests/no_host_imports.rs`).
38//!
39//! `.cargo/config.toml` (repository root) selects getrandom's "custom"
40//! backend for the `wasm32-unknown-unknown` target only (native builds,
41//! including `cargo test`, are unaffected and keep the real OS entropy
42//! source). That backend calls out to the `__getrandom_v03_custom` symbol
43//! defined below instead of any JS API. It refuses the request (`Err`)
44//! rather than fabricating bytes — but that refusal is weaker than it
45//! sounds, and the next paragraph is the actual reason construction must
46//! never draw a GUID or client id from the library, not "so a panic tells
47//! us":
48//!
49//! `fastrand::Rng::new()` — what both `Observer::subscribe()`
50//! (`yrs` `src/observer.rs:128`/`:145`, subscription ids) and `uuid_v4()`
51//! (`yrs` `src/lib.rs:713`, document GUIDs) call — does not propagate our
52//! `Err`. It masks it: `vendor/fastrand-2.5.0/src/global_rng.rs:223` turns
53//! any `getrandom` failure into `None` and falls back to a constant seed.
54//! `Rng::new()` itself then *forks* the thread-local generator from that
55//! seed and advances it, so successive calls within one process still
56//! produce different values — subscription ids don't collide with each
57//! other, and this crate's own tests can't see anything wrong. The actual
58//! hazard is cross-process: the thread-local seed is the same fixed
59//! constant on every process, every time, so the *sequence* of "random"
60//! ids/GUIDs a process draws is identical across processes. Two
61//! independently-created documents that never draw a client id or GUID
62//! explicitly would collide. This crate avoids the whole problem by
63//! supplying both explicitly (see above) rather than relying on `fastrand`
64//! to fail loudly when it can't — it won't.
65pub mod abi;
66pub mod check;
67mod layout;
68pub mod markdown;
69mod ops;
70mod schema;
71mod view;
72
73pub use check::check;
74pub use ops::apply_ops;
75pub use schema::{Paragraph, Run};
76pub use view::view_json;
77
78use yrs::updates::decoder::Decode;
79use yrs::{Doc, Options, ReadTxn, StateVector, Transact, Update};
80
81#[cfg(target_arch = "wasm32")]
82#[no_mangle]
83extern "Rust" fn __getrandom_v03_custom(
84 _dest: *mut u8,
85 _len: usize,
86) -> Result<(), getrandom::Error> {
87 Err(getrandom::Error::UNSUPPORTED)
88}
89
90/// Returns the ABI version this module exports. The Go host checks this on
91/// load and refuses a module it does not understand, so a stale .wasm on a
92/// box fails loudly instead of behaving strangely.
93///
94/// Version 1 was the surface `abi.rs` exported at slice 1: doc lifecycle,
95/// apply, the state/json read pairs, and ops with its out/err pairs.
96/// Version 2 adds `slides_core_check` (codex 3), the accepted-shape
97/// check the host runs on a posted update before storing it. An export
98/// added forces a bump even though it is, strictly, backwards compatible
99/// on the wasm side: the module and the Go binary ship embedded in each
100/// other, and `internal/core`'s check is exact-match, so the bump is what
101/// turns "unknown export" deep inside a request into a refusal at load
102/// time.
103#[no_mangle]
104pub extern "C" fn slides_core_abi_version() -> u32 {
105 2
106}
107
108#[derive(Debug)]
109pub enum CoreError {
110 /// The bytes were not a valid Yjs v1 update.
111 Decode(String),
112 /// The update decoded but could not be applied.
113 Apply(String),
114 /// The operation batch was refused; nothing was applied. The message
115 /// is what the host surfaces to the caller.
116 Ops(String),
117}
118
119impl std::fmt::Display for CoreError {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 CoreError::Decode(m) => write!(f, "decode: {m}"),
123 CoreError::Apply(m) => write!(f, "apply: {m}"),
124 CoreError::Ops(m) => write!(f, "ops: {m}"),
125 }
126 }
127}
128
129impl std::error::Error for CoreError {}
130
131/// A document with the caller's GUID and client id. The one sanctioned
132/// constructor (see the module comment): nothing here draws entropy.
133pub fn new_doc(guid: &str, client_id: u64) -> Doc {
134 Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id))
135}
136
137/// Applies a Yjs v1 update to `doc`.
138pub fn apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError> {
139 let update = Update::decode_v1(update).map_err(|e| CoreError::Decode(e.to_string()))?;
140 let mut txn = doc.transact_mut();
141 txn.apply_update(update)
142 .map_err(|e| CoreError::Apply(e.to_string()))
143}
144
145/// The whole document as one v1 update: `encode_state_as_update_v1`
146/// against the EMPTY state vector, which is the call that carries every
147/// block, the whole delete set and the pending queue (Docs'
148/// `snapshot.rs` explains why the `encode_diff_v1` neighbour is a trap).
149pub fn state_bytes(doc: &Doc) -> Vec<u8> {
150 doc.transact()
151 .encode_state_as_update_v1(&StateVector::default())
152}
153