oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1//! The Docs core: Yjs CRDT and (later) text layout, compiled to wasm and
2//! run both under wazero on the server and — for layout only — in the browser.
3//!
4//! # Important: Doc construction
5//!
6//! Never call `yrs::Doc::new()` *or* `yrs::Doc::with_client_id()` in this
7//! crate. Both draw entropy from `fastrand`, not just `Doc::new()`:
8//! `Doc::new()` picks a random client id *and* GUID; `Doc::with_client_id()`
9//! takes the client id explicitly but still draws a random GUID internally
10//! (`Options::with_client_id`, `yrs` `src/doc.rs:916`, calls `uuid_v4()`
11//! regardless). The only construction that supplies everything itself, and
12//! so is safe to use, is `Doc::with_options(Options::with_guid_and_client_id
13//! (guid, client_id))` (`doc.rs:928`) with both values we choose. This is
14//! enforced by `clippy.toml` (`disallowed-methods`), wired into
15//! `scripts/build-wasm.sh` — not just documented here, because this rule
16//! has been gotten wrong more than once already (see below).
17//!
18//! # Zero host imports and the custom getrandom backend
19//!
20//! On `wasm32-unknown-unknown`, `yrs`'s pinned `fastrand` dependency
21//! requires the `js` feature, which makes the transitively-pulled
22//! `getrandom` crate default to a `wasm-bindgen`-based backend that imports
23//! host JS functions. Merely referencing `yrs::Doc` anywhere in this crate
24//! is enough for those `wasm-bindgen` exports (`__wbindgen_describe`,
25//! `__wbindgen_malloc`, ...) to end up linked into the final module, even
26//! along code paths that are never reached at runtime — which breaks the
27//! zero-imports contract with the Go/wazero host (see
28//! `core/tests/no_host_imports.rs`).
29//!
30//! `docs-wt-core/.cargo/config.toml` selects getrandom's "custom" backend
31//! for the `wasm32-unknown-unknown` target only (native builds, including
32//! `cargo test`, are unaffected and keep the real OS entropy source). That
33//! backend calls out to the `__getrandom_v03_custom` symbol defined below
34//! instead of any JS API. It refuses the request (`Err`) rather than
35//! fabricating bytes — but that refusal is weaker than it sounds, and the
36//! next paragraph is the actual reason construction must never draw a GUID
37//! or client id from the library, not "so a panic tells us":
38//!
39//! `fastrand::Rng::new()` — what both `Observer::subscribe()`
40//! (`yrs` `src/observer.rs:128`/`:145`, subscription ids) and `uuid_v4()`
41//! (`yrs` `src/lib.rs:713`, document GUIDs) call — does not propagate our
42//! `Err`. It masks it: `vendor/fastrand-2.5.0/src/global_rng.rs:223` turns
43//! any `getrandom` failure into `None` and falls back to a constant seed.
44//! `Rng::new()` itself then *forks* the thread-local generator from that
45//! seed and advances it, so successive calls within one process still
46//! produce different values — subscription ids don't collide with each
47//! other, and this crate's own tests (which never construct more than one
48//! `Doc` per process) can't see anything wrong. The actual hazard is
49//! cross-process: the thread-local seed is the same fixed constant on
50//! every process, every time, so the *sequence* of "random" ids/GUIDs a
51//! process draws is identical across processes. Two independently-created
52//! documents that never draw a client id or GUID explicitly would collide.
53//! This crate avoids the whole problem by supplying both explicitly (see
54//! above) rather than relying on `fastrand` to fail loudly when it can't —
55//! it won't.
56pub mod abi;
57mod fragment;
58mod snapshot;
59pub use fragment::{fragment_html, fragment_json, fragment_text, ROOT as FRAGMENT_ROOT};
60pub use snapshot::{snapshot_bytes, verify_snapshot};
61
62#[cfg(target_arch = "wasm32")]
63#[no_mangle]
64extern "Rust" fn __getrandom_v03_custom(
65 _dest: *mut u8,
66 _len: usize,
67) -> Result<(), getrandom::Error> {
68 Err(getrandom::Error::UNSUPPORTED)
69}
70
71/// Returns the ABI version this module exports. The Go host checks this on
72/// load and refuses a module it does not understand, so a stale .wasm on a
73/// box fails loudly instead of behaving strangely.
74///
75/// Bumped to 2 for the HTML read pair in `abi.rs`
76/// (`docs_core_html_len` / `docs_core_html_read`), to 3 for the JSON
77/// pair (`docs_core_json_len` / `docs_core_json_read`) the export paths
78/// read the document's structure through, and to 4 for the snapshot
79/// pair (`docs_core_snapshot_len` / `docs_core_snapshot_read`) that
80/// exports a document's whole state as one update. `docs_core_verify_snapshot`,
81/// which checks a candidate snapshot against a live document, lands
82/// within that same version 4 rather than needing one of its own —
83/// both pairs were added before version 4 ever shipped, so no host in
84/// the field has seen a 4 that lacks this export. That is the only
85/// reason a shared number is safe here: an export added after a
86/// version has already shipped forces its own bump regardless, for the
87/// reason the next paragraph gives.
88///
89/// Adding an export is, strictly, backwards compatible — a host that
90/// never calls the new function cannot tell 3 from 2 — so the bump is
91/// not forced by the wasm itself. It is forced by the host: the module
92/// and the Go binary ship embedded in each other with no version skew
93/// between separately deployed copies, and `internal/core`'s
94/// `abiVersion` is an exact-match check. A host that calls
95/// `docs_core_json_len` against a stale module gets "unknown export" at
96/// call time, deep inside a request; bumping means it gets a refusal at
97/// load time instead, which is the whole point of there being a version
98/// here.
99#[no_mangle]
100pub extern "C" fn docs_core_abi_version() -> u32 {
101 4
102}
103
104use yrs::updates::decoder::Decode;
105use yrs::{Doc, GetString, Transact, Update};
106
107#[derive(Debug)]
108pub enum CoreError {
109 /// The bytes were not a valid Yjs v1 update.
110 Decode(String),
111 /// The update decoded but could not be applied.
112 Apply(String),
113}
114
115impl std::fmt::Display for CoreError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 CoreError::Decode(m) => write!(f, "decode: {m}"),
119 CoreError::Apply(m) => write!(f, "apply: {m}"),
120 }
121 }
122}
123
124/// Applies a Yjs v1 update to `doc`.
125pub fn apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError> {
126 let update = Update::decode_v1(update).map_err(|e| CoreError::Decode(e.to_string()))?;
127 let mut txn = doc.transact_mut();
128 txn.apply_update(update)
129 .map_err(|e| CoreError::Apply(e.to_string()))
130}
131
132/// The plain-text content of the document's root text type. The name
133/// "content" matches what the fixture generator writes; it is the CRDT root
134/// name and is part of the wire contract.
135pub fn text_content(doc: &Doc) -> String {
136 let text = doc.get_or_insert_text("content");
137 let txn = doc.transact();
138 text.get_string(&txn)
139}
140