├── .github └── workflows │ └── build.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── ape.svg ├── aper-leptos ├── Cargo.toml └── src │ ├── init_tracing.rs │ └── lib.rs ├── aper-serve ├── Cargo.toml └── src │ └── lib.rs ├── aper-stateroom ├── Cargo.toml └── src │ └── lib.rs ├── aper-websocket-client ├── Cargo.toml └── src │ ├── client.rs │ ├── lib.rs │ ├── typed.rs │ └── websocket.rs ├── aper-yew ├── Cargo.toml └── src │ └── lib.rs ├── aper ├── Cargo.toml ├── aper-derive │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── src │ ├── aper.rs │ ├── connection.rs │ ├── data_structures │ │ ├── atom.rs │ │ ├── atom_map.rs │ │ ├── fixed_array.rs │ │ ├── map.rs │ │ └── mod.rs │ ├── lib.rs │ ├── listener.rs │ └── store │ │ ├── core.rs │ │ ├── handle.rs │ │ ├── iter.rs │ │ ├── mod.rs │ │ └── prefix_map.rs └── tests │ ├── backtrack.rs │ ├── listener.rs │ ├── lock.rs │ ├── simple-client-server.rs │ ├── store-cleanup.rs │ └── tic-tac-toe.rs ├── examples ├── .gitignore ├── README.md ├── counter-leptos │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── client │ │ ├── .cargo │ │ │ └── config.toml │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── common │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── service │ │ ├── .cargo │ │ │ └── config │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── stateroom.toml │ └── static │ │ └── index.html ├── counter │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── client │ │ ├── .cargo │ │ │ └── config │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── common │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── service │ │ ├── .cargo │ │ │ └── config │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── stateroom.toml │ └── static │ │ └── index.html ├── drop-four │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── client │ │ ├── .cargo │ │ │ └── config │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── board_component.rs │ │ │ └── lib.rs │ ├── common │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── lib.rs │ │ │ └── state.rs │ ├── service │ │ ├── .cargo │ │ │ └── config │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── stateroom.toml │ └── static │ │ ├── index.html │ │ └── style.css └── timer │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── client │ ├── .cargo │ │ └── config │ ├── Cargo.toml │ └── src │ │ └── lib.rs │ ├── common │ ├── Cargo.toml │ └── src │ │ └── lib.rs │ ├── service │ ├── .cargo │ │ └── config │ ├── Cargo.toml │ └── src │ │ └── lib.rs │ ├── stateroom.toml │ └── static │ └── index.html └── site ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── README.md ├── book ├── .gitignore ├── book.toml └── src │ ├── 01-introduction.md │ ├── 02-one-way-sync.md │ ├── 03-bidirectional-sync.md │ └── SUMMARY.md ├── build.sh ├── doc ├── Cargo.toml └── src │ └── main.rs ├── doctests ├── Cargo.toml └── src │ └── lib.rs └── website ├── .gitignore ├── 2021-rust-and-tell ├── .gitignore ├── LICENSE ├── index.html ├── media │ ├── ape.svg │ ├── aper-sketch-intent.svg │ ├── aper-sketch.svg │ ├── demo.mov │ ├── drop-four-state-flow.svg │ └── packing.svg └── plugin │ ├── highlight │ ├── highlight.esm.js │ ├── highlight.js │ ├── monokai.css │ ├── plugin.js │ └── zenburn.css │ ├── markdown │ ├── markdown.esm.js │ ├── markdown.js │ └── plugin.js │ ├── math │ ├── math.esm.js │ ├── math.js │ └── plugin.js │ ├── notes │ ├── notes.esm.js │ ├── notes.js │ ├── plugin.js │ └── speaker-view.html │ ├── search │ ├── plugin.js │ ├── search.esm.js │ └── search.js │ └── zoom │ ├── plugin.js │ ├── zoom.esm.js │ └── zoom.js ├── 2022-rust-nyc ├── .gitignore ├── LICENSE ├── index.html ├── media │ ├── ape.svg │ ├── aper-sketch-intent.svg │ ├── aper-sketch.svg │ ├── demo.mov │ ├── drop-four-state-flow.svg │ ├── nondeterminism.png │ ├── packing.svg │ ├── realtime-blogpost.png │ └── wasmbox.png └── plugin │ ├── highlight │ ├── highlight.esm.js │ ├── highlight.js │ ├── monokai.css │ ├── plugin.js │ └── zenburn.css │ ├── markdown │ ├── markdown.esm.js │ ├── markdown.js │ └── plugin.js │ ├── math │ ├── math.esm.js │ ├── math.js │ └── plugin.js │ ├── notes │ ├── notes.esm.js │ ├── notes.js │ ├── plugin.js │ └── speaker-view.html │ ├── search │ ├── plugin.js │ ├── search.esm.js │ └── search.js │ └── zoom │ ├── plugin.js │ ├── zoom.esm.js │ └── zoom.js ├── LICENSE ├── README.md ├── _cobalt.yml ├── _layouts └── default.liquid ├── ape.svg ├── favicon.png ├── index.md ├── static ├── github.svg └── twitter.svg └── style.css /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: Swatinem/rust-cache@v1 19 | with: 20 | key: '20240901' 21 | cache-on-failure: true 22 | - name: Build 23 | run: cargo build --verbose 24 | - name: Run tests 25 | run: cargo test --verbose 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target 3 | .DS_Store 4 | dist 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | exclude = [ 5 | "./examples" 6 | ] 7 | 8 | members = [ 9 | "aper-leptos", 10 | "aper-serve", 11 | "aper-stateroom", 12 | "aper-websocket-client", 13 | "aper-yew", 14 | "aper", 15 | "aper/aper-derive*", 16 | "site/doctests", 17 | ] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paul Butler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Aper](https://aper.dev) 2 | 3 | [![GitHub Repo stars](https://img.shields.io/github/stars/drifting-in-space/aper?style=social)](https://github.com/drifting-in-space/aper) 4 | [![crates.io](https://img.shields.io/crates/v/aper.svg)](https://crates.io/crates/aper) 5 | [![docs.rs](https://img.shields.io/badge/docs-release-brightgreen)](https://docs.rs/aper/) 6 | [![wokflow state](https://github.com/drifting-in-space/aper/workflows/build/badge.svg)](https://github.com/drifting-in-space/aper/actions/workflows/rust.yml) 7 | 8 | Aper is a Rust library for data synchronization over a network. 9 | 10 | Aper supports optimistic updates and arbitrary business logic, making it useful for real-time collabrative and agentic use cases. 11 | 12 | ## Introduction 13 | 14 | **(Aper is mid-refactor. Docs and examples may be out of date.)** 15 | 16 | Types marked with the `AperSync` trait can be stored in the `Store`, Aper's synchronizable data store. 17 | Aper includes several data structures that implement `AperSync` in the `aper::data_structures` module, which 18 | can be used as building blocks to build your own synchronizable types. 19 | 20 | You can use these, along with the `AperSync` derive macro, to compose structs that also implement `AperSync`. 21 | 22 | ```rust 23 | use aper::{AperSync, data_structures::{Atom, Map}}; 24 | use uuid::Uuid; 25 | 26 | #[derive(AperSync)] 27 | struct ToDoItem { 28 | pub done: Atom, 29 | pub name: Atom, 30 | } 31 | 32 | #[derive(AperSync)] 33 | struct ToDoList { 34 | pub items: Map, 35 | } 36 | ``` 37 | 38 | To synchronize from the server to clients, Aper replicates changes to the `Store` when it receives them. To synchronize 39 | from clients to servers, we instead send *intents* to the server. 40 | 41 | Intents are represented as a serializable `enum` representing every possible action a user might take on the data. 42 | For example, in our to-do list, that represents creating a task, renaming a task, marking a task as (not) done, or 43 | removing completed items. 44 | 45 | ```rust 46 | use aper::Aper; 47 | 48 | #[derive(Serialize, Deserialize, Clone, std::cmp::PartialEq)] 49 | enum ToDoIntent { 50 | CreateTask { 51 | id: Uuid, 52 | name: String, 53 | }, 54 | RenameTask { 55 | id: Uuid, 56 | name: String, 57 | }, 58 | MarkDone { 59 | id: Uuid, 60 | done: bool, 61 | }, 62 | RemoveCompleted, 63 | } 64 | 65 | impl Aper for ToDoList { 66 | type Intent = ToDoIntent; 67 | type Error = (); 68 | 69 | fn apply(&mut self, intent: &ToDoIntent) -> Result<(), ()> { 70 | match intent { 71 | ToDoIntent::CreateTask { id, name } => { 72 | let mut item = self.items.get_or_create(id); 73 | item.name.set(name.to_string()); 74 | item.done.set(false); 75 | }, 76 | ToDoIntent::RenameTask { id, name } => { 77 | // Unlike CreateTask, we bail early with an `Err` if 78 | // the item doesn't exist. Most likely, the server has 79 | // seen a `RemoveCompleted` that removed the item, but 80 | // a client attempted to rename it before the removal 81 | // was synced to it. 82 | let mut item = self.items.get(id).ok_or(())?; 83 | item.name.set(name.to_string()); 84 | } 85 | ToDoIntent::MarkDone { id, done } => { 86 | let mut item = self.items.get(id).ok_or(())?; 87 | item.done.set(*done); 88 | } 89 | ToDoIntent::RemoveCompleted => { 90 | // TODO: need to implement .iter() on Map first. 91 | } 92 | } 93 | 94 | Ok(()) 95 | } 96 | } 97 | ``` 98 | 99 | --- 100 | 101 | **Aper is rapidly evolving. Consider this a technology preview.** See the [list of issues outstanding for version 1.0](https://github.com/drifting-in-space/aper/labels/v1-milestone) 102 | 103 | - [Documentation](https://docs.rs/aper/) 104 | - [Examples](https://github.com/drifting-in-space/aper/tree/main/examples) 105 | - [Talk on Aper for Rust Berlin (20 minute video)](https://www.youtube.com/watch?v=HNzeouj0eKc&t=1852s) 106 | -------------------------------------------------------------------------------- /aper-leptos/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-leptos" 3 | version = "0.5.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | aper = {path = "../aper"} 8 | aper-websocket-client = { version="0.5.0", path="../aper-websocket-client" } 9 | leptos = { version = "0.6.14", features = ["csr"] } 10 | serde = "1.0.210" 11 | tracing-subscriber = "0.3.18" 12 | tracing-web = "0.1.3" 13 | -------------------------------------------------------------------------------- /aper-leptos/src/init_tracing.rs: -------------------------------------------------------------------------------- 1 | use tracing_subscriber::fmt::format::Pretty; 2 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 3 | use tracing_web::{performance_layer, MakeWebConsoleWriter}; 4 | 5 | pub fn init_tracing() { 6 | let fmt_layer = tracing_subscriber::fmt::layer() 7 | .with_ansi(false) // Only partially supported across browsers 8 | .without_time() // std::time is not available in browsers 9 | .with_writer(MakeWebConsoleWriter::new()); // write events to the console 10 | let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); 11 | 12 | tracing_subscriber::registry() 13 | .with(fmt_layer) 14 | .with(perf_layer) 15 | .init(); 16 | } 17 | -------------------------------------------------------------------------------- /aper-leptos/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::{data_structures::Atom, AperSync}; 2 | use leptos::{create_signal, ReadSignal, SignalSet}; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub mod init_tracing; 6 | 7 | pub trait Watch { 8 | fn watch(&self) -> ReadSignal; 9 | } 10 | 11 | impl Watch for Atom 12 | where 13 | T: Serialize + DeserializeOwned + Default + Clone + Send + Sync + 'static, 14 | { 15 | fn watch(&self) -> ReadSignal { 16 | let (signal, set_signal) = create_signal(self.get()); 17 | 18 | let self_clone = self.clone(); 19 | self.listen(move || { 20 | set_signal.set(self_clone.get()); 21 | true 22 | }); 23 | 24 | signal 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /aper-serve/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-serve" 3 | version = "0.2.2" 4 | edition = "2018" 5 | repository = "https://github.com/drifting-in-space/aper" 6 | publish = false 7 | 8 | [dependencies] 9 | aper = { path = "../aper" } 10 | aper-stateroom = {path = "../aper-stateroom"} 11 | env_logger = "0.11.3" 12 | stateroom-server = { version="0.4.0" } 13 | log = "0.4.17" 14 | stateroom = { version="0.4.4", features=["serde"] } 15 | -------------------------------------------------------------------------------- /aper-serve/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::Aper; 2 | use aper_stateroom::AperStateroomService; 3 | use env_logger::Builder; 4 | use stateroom::DefaultStateroomFactory; 5 | use stateroom_server::Server; 6 | 7 | pub fn serve() -> std::io::Result<()> 8 | where 9 | F: Aper + Send + Sync + 'static, 10 | F::Intent: Unpin + Send + Sync + 'static, 11 | { 12 | let mut builder = Builder::new(); 13 | builder.filter(Some("stateroom_server"), log::LevelFilter::Info); 14 | builder.filter(Some("stateroom_wasm_host"), log::LevelFilter::Info); 15 | builder.init(); 16 | 17 | let host_factory: DefaultStateroomFactory> = 18 | DefaultStateroomFactory::default(); 19 | 20 | let server = Server::new(); 21 | 22 | server.serve(host_factory) 23 | } 24 | -------------------------------------------------------------------------------- /aper-stateroom/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-stateroom" 3 | version = "0.5.0" 4 | edition = "2018" 5 | repository = "https://github.com/drifting-in-space/aper" 6 | homepage = "https://aper.dev" 7 | description = "Synchronized state machines over WebSockets" 8 | license = "MIT" 9 | readme = "../README.md" 10 | keywords = ["webassembly"] 11 | categories = ["data-structures", "web-programming::websocket", "wasm"] 12 | 13 | [dependencies] 14 | stateroom = { version="0.4.4", features=["serde"] } 15 | aper = { path = "../aper" } 16 | serde = "1.0.143" 17 | serde_json = "1.0.75" 18 | bincode = "1.3.3" 19 | log = "0.4.17" 20 | chrono = { version = "0.4.22", features = ["serde"] } 21 | -------------------------------------------------------------------------------- /aper-stateroom/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::connection::{MessageToClient, MessageToServer, ServerConnection, ServerHandle}; 2 | use aper::{Aper, IntentMetadata}; 3 | use chrono::Utc; 4 | pub use stateroom::ClientId; 5 | use stateroom::{MessagePayload, StateroomContext, StateroomService}; 6 | use std::collections::HashMap; 7 | 8 | pub struct AperStateroomService

9 | where 10 | P: Aper, 11 | P::Intent: Unpin + 'static, 12 | { 13 | connection: ServerConnection

, 14 | suspended_event: Option<(P::Intent, IntentMetadata)>, 15 | client_connections: HashMap>, 16 | 17 | /// Pseudo-connection for sending timer events. 18 | timer_event_handle: ServerHandle

, 19 | } 20 | 21 | impl Default for AperStateroomService

22 | where 23 | P: Aper, 24 | P::Intent: Unpin + 'static, 25 | { 26 | fn default() -> Self { 27 | let mut connection = ServerConnection::new(); 28 | let timer_event_handle = connection.connect(|_| {}); 29 | 30 | AperStateroomService { 31 | connection, 32 | suspended_event: None, 33 | client_connections: HashMap::new(), 34 | timer_event_handle, 35 | } 36 | } 37 | } 38 | 39 | impl AperStateroomService

40 | where 41 | P: Aper, 42 | P::Intent: Unpin + 'static, 43 | { 44 | fn update_suspended_event(&mut self, ctx: &impl StateroomContext) { 45 | let susp = self.connection.state().suspended_event(); 46 | if susp == self.suspended_event { 47 | return; 48 | } 49 | 50 | if let Some(ev) = &susp { 51 | let dur = ev.1.timestamp.signed_duration_since(Utc::now()); 52 | ctx.set_timer(dur.num_milliseconds().max(0) as u32); 53 | } 54 | 55 | self.suspended_event = susp; 56 | } 57 | 58 | fn process_message( 59 | &mut self, 60 | message: MessageToServer, 61 | client_id: Option, 62 | ctx: &impl StateroomContext, 63 | ) { 64 | if let Some(handle) = client_id.and_then(|id| self.client_connections.get_mut(&id)) { 65 | handle.receive(&message); 66 | } else { 67 | self.timer_event_handle.receive(&message); 68 | } 69 | 70 | self.update_suspended_event(ctx); 71 | } 72 | } 73 | 74 | impl StateroomService for AperStateroomService

75 | where 76 | P: Aper + Send + Sync, 77 | P::Intent: Unpin + Send + Sync + 'static, 78 | { 79 | fn init(&mut self, ctx: &impl StateroomContext) { 80 | self.update_suspended_event(ctx); 81 | } 82 | 83 | fn connect(&mut self, client_id: ClientId, ctx: &impl StateroomContext) { 84 | let ctx = Clone::clone(ctx); 85 | let callback = move |message: &MessageToClient| { 86 | ctx.send_message(client_id, bincode::serialize(&message).unwrap()); 87 | }; 88 | 89 | let handle = self.connection.connect(callback); 90 | 91 | self.client_connections.insert(client_id, handle); 92 | } 93 | 94 | fn disconnect(&mut self, user: ClientId, _ctx: &impl StateroomContext) { 95 | self.client_connections.remove(&user); 96 | } 97 | 98 | fn message( 99 | &mut self, 100 | client_id: ClientId, 101 | message: MessagePayload, 102 | ctx: &impl StateroomContext, 103 | ) { 104 | match message { 105 | MessagePayload::Text(txt) => { 106 | let message: MessageToServer = serde_json::from_str(&txt).unwrap(); 107 | self.process_message(message, Some(client_id), ctx); 108 | } 109 | MessagePayload::Bytes(bytes) => { 110 | let message: MessageToServer = bincode::deserialize(&bytes).unwrap(); 111 | self.process_message(message, Some(client_id), ctx); 112 | } 113 | } 114 | } 115 | 116 | fn timer(&mut self, ctx: &impl StateroomContext) { 117 | if let Some(mut event) = self.suspended_event.take() { 118 | event.1.timestamp = Utc::now(); 119 | let event = bincode::serialize(&event).unwrap(); 120 | self.process_message( 121 | MessageToServer::Intent { 122 | intent: event, 123 | client_version: 0, 124 | }, 125 | None, 126 | ctx, 127 | ); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /aper-websocket-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-websocket-client" 3 | version = "0.5.0" 4 | edition = "2021" 5 | repository = "https://github.com/drifting-in-space/aper" 6 | description = "Synchronized state machines over WebSockets" 7 | license = "MIT" 8 | readme = "../README.md" 9 | 10 | [dependencies] 11 | anyhow = "1.0.62" 12 | aper = { version="0.5.0", path = "../aper" } 13 | bincode = "1.3.3" 14 | chrono = { version = "0.4.22", features = ["serde", "wasmbind"] } 15 | js-sys = "0.3.59" 16 | serde = "1.0.143" 17 | serde_json = "1.0.74" 18 | tracing = "0.1.40" 19 | wasm-bindgen = "0.2.82" 20 | web-sys = { version = "0.3.59", features = ["BinaryType", "WebSocket", "MessageEvent"] } 21 | -------------------------------------------------------------------------------- /aper-websocket-client/src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::typed::TypedWebsocketConnection; 2 | use anyhow::Result; 3 | use aper::{ 4 | connection::{ClientConnection, MessageToClient, MessageToServer}, 5 | Aper, AperClient, Store, 6 | }; 7 | use core::fmt::Debug; 8 | use std::{ 9 | rc::{Rc, Weak}, 10 | sync::Mutex, 11 | }; 12 | 13 | #[derive(Clone)] 14 | pub struct AperWebSocketClient 15 | where 16 | S: Aper, 17 | { 18 | conn: Rc>>, 19 | } 20 | 21 | impl PartialEq for AperWebSocketClient 22 | where 23 | T: Aper, 24 | { 25 | fn eq(&self, _other: &Self) -> bool { 26 | // only equal if they are the same instance 27 | std::ptr::eq(self.conn.as_ref(), _other.conn.as_ref()) 28 | } 29 | } 30 | 31 | impl Debug for AperWebSocketClient 32 | where 33 | S: Aper, 34 | { 35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 | f.debug_struct("AperWebSocketStateProgramClient").finish() 37 | } 38 | } 39 | 40 | impl AperWebSocketClient 41 | where 42 | S: Aper, 43 | { 44 | pub fn new(url: &str) -> Result { 45 | // callback is called when the state changes 46 | // need to create a connection 47 | // connection needs to be able to call the state and message callback 48 | 49 | // client message handler needs to have websocket connection; websocket 50 | // connection needs to be able to send messages to client 51 | 52 | let client = AperClient::::new(); 53 | 54 | let conn = Rc::new_cyclic(|c: &Weak>>| { 55 | let d = c.clone(); 56 | let socket_message_callback = move |message: MessageToClient| { 57 | let d = d.upgrade().unwrap(); 58 | let mut conn = d.lock().unwrap(); 59 | conn.receive(&message); 60 | }; 61 | 62 | let wss_conn = TypedWebsocketConnection::new(url, socket_message_callback).unwrap(); 63 | 64 | let message_callback = Box::new(move |message: MessageToServer| { 65 | wss_conn.send(&message); 66 | }); 67 | 68 | Mutex::new(ClientConnection::new(client, message_callback)) 69 | }); 70 | 71 | Ok(AperWebSocketClient { conn }) 72 | } 73 | 74 | pub fn store(&self) -> Store { 75 | self.conn.lock().unwrap().store() 76 | } 77 | 78 | pub fn state(&self) -> S { 79 | let store = self.store(); 80 | S::attach(store.handle()) 81 | } 82 | 83 | pub fn apply(&self, intent: S::Intent) -> Result<(), S::Error> { 84 | let mut conn = self.conn.lock().unwrap(); 85 | 86 | conn.apply(intent) 87 | } 88 | 89 | pub fn client_id(&self) -> Option { 90 | self.conn.lock().unwrap().client_id() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /aper-websocket-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod typed; 3 | mod websocket; 4 | 5 | pub use client::AperWebSocketClient; 6 | -------------------------------------------------------------------------------- /aper-websocket-client/src/typed.rs: -------------------------------------------------------------------------------- 1 | use crate::websocket::{Message, WebSocketConnection}; 2 | use anyhow::Result; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | use std::marker::PhantomData; 5 | 6 | pub struct TypedWebsocketConnection 7 | where 8 | F: Fn(Inbound) + 'static, 9 | { 10 | _ph: PhantomData<(Inbound, Outbound, F)>, 11 | conn: WebSocketConnection>, 12 | } 13 | 14 | impl 15 | TypedWebsocketConnection 16 | where 17 | F: Fn(Inbound) + 'static, 18 | { 19 | pub fn new(url: &str, callback: F) -> Result { 20 | let f: Box = Box::new(move |m: Message| match m { 21 | Message::Text(txt) => { 22 | let result: Inbound = serde_json::from_str(&txt).unwrap(); 23 | callback(result); 24 | } 25 | Message::Bytes(bytes) => { 26 | let result: Inbound = bincode::deserialize(&bytes).unwrap(); 27 | callback(result); 28 | } 29 | }); 30 | let conn = WebSocketConnection::new(url, f)?; 31 | 32 | Ok(TypedWebsocketConnection { 33 | conn, 34 | _ph: PhantomData, 35 | }) 36 | } 37 | 38 | pub fn send(&self, message: &Outbound) { 39 | let message = Message::Bytes(bincode::serialize(message).unwrap()); 40 | self.conn.send(&message); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /aper-websocket-client/src/websocket.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use std::sync::Arc; 3 | use std::{marker::PhantomData, sync::Mutex}; 4 | use wasm_bindgen::JsCast; 5 | use wasm_bindgen::{prelude::Closure, JsValue}; 6 | use web_sys::{MessageEvent, WebSocket}; 7 | 8 | pub struct WebSocketConnection 9 | where 10 | F: Fn(Message) + 'static, 11 | { 12 | socket: WebSocket, 13 | _message_handler: Closure, 14 | _conn_handler: Closure, 15 | _ph: PhantomData, 16 | 17 | pending: Arc>>, 18 | } 19 | 20 | #[derive(Clone)] 21 | pub enum Message { 22 | Text(String), 23 | Bytes(Vec), 24 | } 25 | 26 | impl WebSocketConnection 27 | where 28 | F: Fn(Message) + 'static, 29 | { 30 | pub fn new(url: &str, callback: F) -> Result { 31 | let ws = 32 | WebSocket::new(url).map_err(|err| anyhow!("Error creating websocket. {:?}", err))?; 33 | ws.set_binary_type(web_sys::BinaryType::Arraybuffer); 34 | 35 | let message_handler = Closure::::wrap(Box::new(move |e: MessageEvent| { 36 | if let Ok(abuf) = e.data().dyn_into::() { 37 | let array = js_sys::Uint8Array::new(&abuf); 38 | let array = array.to_vec(); 39 | 40 | callback(Message::Bytes(array)); 41 | } else if let Ok(txt) = e.data().dyn_into::() { 42 | let txt = txt.as_string().unwrap(); 43 | 44 | callback(Message::Text(txt)); 45 | } else { 46 | panic!("message event, received Unknown: {:?}", e.data()); 47 | } 48 | })); 49 | 50 | ws.set_onmessage(Some(message_handler.as_ref().unchecked_ref())); 51 | 52 | let pending = Arc::new(Mutex::new(None)); 53 | let pending_ = pending.clone(); 54 | let ws_ = ws.clone(); 55 | let conn_handler = Closure::::wrap(Box::new(move |_: JsValue| { 56 | let mut pending = pending_.lock().unwrap(); 57 | if let Some(message) = pending.take() { 58 | match message { 59 | Message::Text(txt) => { 60 | ws_.send_with_str(&txt).unwrap(); 61 | } 62 | Message::Bytes(bytes) => { 63 | ws_.send_with_u8_array(&bytes).unwrap(); 64 | } 65 | } 66 | } 67 | })); 68 | 69 | ws.set_onopen(Some(conn_handler.as_ref().unchecked_ref())); 70 | 71 | Ok(WebSocketConnection { 72 | socket: ws, 73 | _message_handler: message_handler, 74 | _conn_handler: conn_handler, 75 | _ph: PhantomData, 76 | pending, 77 | }) 78 | } 79 | 80 | pub fn send(&self, message: &Message) { 81 | // if the socket is not open, queue the message 82 | if self.socket.ready_state() != WebSocket::OPEN { 83 | let mut pending = self.pending.lock().unwrap(); 84 | *pending = Some(message.clone()); 85 | return; 86 | } 87 | 88 | match message { 89 | Message::Text(txt) => { 90 | self.socket.send_with_str(txt).unwrap(); 91 | } 92 | Message::Bytes(bytes) => { 93 | self.socket.send_with_u8_array(bytes).unwrap(); 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /aper-yew/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | version = "0.5.0" 3 | name = "aper-yew" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | yew = "0.21.0" 8 | aper = { path = "../aper" } 9 | aper-websocket-client = { path = "../aper-websocket-client" } 10 | -------------------------------------------------------------------------------- /aper-yew/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::{Aper, Store}; 2 | use aper_websocket_client::AperWebSocketClient; 3 | use yew::Callback; 4 | 5 | pub struct FakeSend { 6 | pub value: T, 7 | } 8 | 9 | impl FakeSend { 10 | pub fn new(value: T) -> Self { 11 | FakeSend { value } 12 | } 13 | } 14 | 15 | unsafe impl Send for FakeSend {} 16 | unsafe impl Sync for FakeSend {} 17 | 18 | #[derive(Clone)] 19 | pub struct YewAperClient { 20 | client: AperWebSocketClient, 21 | } 22 | 23 | impl PartialEq for YewAperClient { 24 | fn eq(&self, _other: &Self) -> bool { 25 | // only equal if they are the same instance 26 | self.client == _other.client 27 | } 28 | } 29 | 30 | impl YewAperClient { 31 | pub fn new(url: &str) -> Self { 32 | let client = AperWebSocketClient::new(url).unwrap(); 33 | YewAperClient { client } 34 | } 35 | 36 | pub fn state(&self) -> T { 37 | self.client.state() 38 | } 39 | 40 | pub fn store(&self) -> Store { 41 | self.client.store() 42 | } 43 | 44 | pub fn apply(&self, intent: T::Intent) -> Result<(), T::Error> { 45 | self.client.apply(intent) 46 | } 47 | 48 | pub fn callback(&self, func: impl Fn() -> T::Intent + 'static) -> Callback { 49 | let client = self.clone(); 50 | 51 | Callback::from(move |_| { 52 | let intent = func(); 53 | let _ = client.apply(intent); 54 | }) 55 | } 56 | 57 | pub fn client_id(&self) -> Option { 58 | self.client.client_id() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /aper/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper" 3 | version = "0.5.0" 4 | edition = "2021" 5 | homepage = "https://aper.dev" 6 | description = "Synchronized state machines over WebSockets" 7 | license = "MIT" 8 | readme = "../README.md" 9 | keywords = ["webassembly"] 10 | categories = ["data-structures", "web-programming::websocket", "wasm"] 11 | 12 | [dependencies] 13 | bincode = "1.3.3" 14 | dashmap = "6.0.1" 15 | serde = { version = "1.0.204", features = ["derive"] } 16 | aper_derive = {path = "./aper-derive", version="0.5.0"} 17 | chrono = { version = "0.4.38", features = ["serde"] } 18 | tracing = "0.1.40" 19 | self_cell = "1.0.4" 20 | bytes = { version = "1.7.1", features = ["serde"] } 21 | -------------------------------------------------------------------------------- /aper/aper-derive/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /aper/aper-derive/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aper-derive" 7 | version = "0.1.0" 8 | dependencies = [ 9 | "proc-macro2", 10 | "quote", 11 | "syn", 12 | ] 13 | 14 | [[package]] 15 | name = "proc-macro2" 16 | version = "1.0.86" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 19 | dependencies = [ 20 | "unicode-ident", 21 | ] 22 | 23 | [[package]] 24 | name = "quote" 25 | version = "1.0.36" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 28 | dependencies = [ 29 | "proc-macro2", 30 | ] 31 | 32 | [[package]] 33 | name = "syn" 34 | version = "2.0.72" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" 37 | dependencies = [ 38 | "proc-macro2", 39 | "quote", 40 | "unicode-ident", 41 | ] 42 | 43 | [[package]] 44 | name = "unicode-ident" 45 | version = "1.0.12" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 48 | -------------------------------------------------------------------------------- /aper/aper-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper_derive" 3 | version = "0.5.0" 4 | edition = "2021" 5 | homepage = "https://aper.dev" 6 | description = "Synchronized state machines over WebSockets" 7 | license = "MIT" 8 | readme = "../../README.md" 9 | keywords = ["webassembly"] 10 | categories = ["data-structures", "web-programming::websocket", "wasm"] 11 | 12 | [lib] 13 | proc-macro = true 14 | 15 | [dependencies] 16 | proc-macro2 = "1.0.86" 17 | quote = "1.0.36" 18 | syn = { version = "2.0.72", features = ["full"] } 19 | -------------------------------------------------------------------------------- /aper/aper-derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::Ident; 2 | use proc_macro2::Literal; 3 | use proc_macro2::TokenStream; 4 | use quote::quote; 5 | use std::collections::BTreeSet; 6 | 7 | #[proc_macro_derive(AperSync)] 8 | pub fn attach_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { 9 | let state = MacroState::from_tokens(input.into()); 10 | let result = state.generate_impl(); 11 | result.into() 12 | } 13 | 14 | enum StructType { 15 | Record(BTreeSet), 16 | Tuple(usize), 17 | Unit, 18 | } 19 | 20 | struct MacroState { 21 | name: Ident, 22 | fields: StructType, 23 | } 24 | 25 | impl MacroState { 26 | fn from_tokens(tokens: TokenStream) -> Self { 27 | let ast = syn::parse2::(tokens.clone()).unwrap(); 28 | let name = ast.ident; 29 | let fields = match ast.fields { 30 | syn::Fields::Named(fields) => { 31 | let fields = fields 32 | .named 33 | .iter() 34 | .map(|field| { 35 | let name = field.ident.as_ref().unwrap().to_string(); 36 | name 37 | }) 38 | .collect(); 39 | StructType::Record(fields) 40 | } 41 | syn::Fields::Unnamed(fields) => { 42 | let fields = fields.unnamed.len(); 43 | StructType::Tuple(fields) 44 | } 45 | syn::Fields::Unit => StructType::Unit, 46 | }; 47 | Self { name, fields } 48 | } 49 | 50 | fn generate_impl(&self) -> TokenStream { 51 | let name = &self.name; 52 | let fields = match &self.fields { 53 | StructType::Record(fields) => { 54 | let fields = fields.iter().map(|field| { 55 | let field = syn::Ident::new(field, proc_macro2::Span::call_site()); 56 | let name = Literal::byte_string(field.to_string().as_bytes()); 57 | quote! { 58 | #field: aper::AperSync::attach(store.child( 59 | aper::Bytes::from_static(#name) 60 | )) 61 | } 62 | }); 63 | quote! { 64 | #name { 65 | #(#fields),* 66 | } 67 | } 68 | } 69 | StructType::Tuple(fields) => { 70 | let fields = (0..*fields).map(|i| { 71 | let i = Literal::byte_string(i.to_be_bytes().as_slice()); 72 | quote! { 73 | aper::AperSync::attach(store.child( 74 | aper::Bytes::from_static(#i) 75 | )) 76 | } 77 | }); 78 | quote! { 79 | #name(#(#fields),*) 80 | } 81 | } 82 | StructType::Unit => quote! { 83 | #name 84 | }, 85 | }; 86 | 87 | quote! { 88 | impl aper::AperSync for #name { 89 | fn attach(mut store: aper::StoreHandle) -> Self { 90 | #fields 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | 101 | #[test] 102 | fn test_generate_impl_for_empty_struct() { 103 | let input = quote! { 104 | struct MyStruct; 105 | }; 106 | 107 | let state = MacroState::from_tokens(input); 108 | let result = state.generate_impl(); 109 | 110 | let expected = quote! { 111 | impl aper::AperSync for MyStruct { 112 | fn attach(mut store: aper::StoreHandle) -> Self { 113 | MyStruct 114 | } 115 | } 116 | }; 117 | 118 | assert_eq!(result.to_string(), expected.to_string()); 119 | } 120 | 121 | #[test] 122 | fn test_generate_impl_for_struct_with_named_fields() { 123 | let input = quote! { 124 | struct MyStruct { 125 | field1: i32, 126 | field2: String, 127 | } 128 | }; 129 | 130 | let state = MacroState::from_tokens(input); 131 | let result = state.generate_impl(); 132 | 133 | let expected = quote! { 134 | impl aper::AperSync for MyStruct { 135 | fn attach(mut store: aper::StoreHandle) -> Self { 136 | MyStruct { 137 | field1: aper::AperSync::attach(store.child(aper::Bytes::from_static(b"field1"))), 138 | field2: aper::AperSync::attach(store.child(aper::Bytes::from_static(b"field2"))) 139 | } 140 | } 141 | } 142 | }; 143 | 144 | assert_eq!(result.to_string(), expected.to_string()); 145 | } 146 | 147 | #[test] 148 | fn test_generate_impl_for_struct_with_tuple_fields() { 149 | let input = quote! { 150 | struct MyStruct(i32, String); 151 | }; 152 | 153 | let state = MacroState::from_tokens(input); 154 | let result = state.generate_impl(); 155 | 156 | let expected = quote! { 157 | impl aper::AperSync for MyStruct { 158 | fn attach(mut store: aper::StoreHandle) -> Self { 159 | MyStruct( 160 | aper::AperSync::attach(store.child(aper::Bytes::from_static(b"\0\0\0\0\0\0\0\0"))), 161 | aper::AperSync::attach(store.child(aper::Bytes::from_static(b"\0\0\0\0\0\0\0\x01"))) 162 | ) 163 | } 164 | } 165 | }; 166 | 167 | assert_eq!(result.to_string(), expected.to_string()); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /aper/src/aper.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | connection::{ClientConnection, MessageToServer}, 3 | store::{Store, StoreHandle}, 4 | IntentMetadata, Mutation, 5 | }; 6 | use serde::{Deserialize, Serialize}; 7 | use std::{collections::VecDeque, fmt::Debug}; 8 | 9 | pub trait AperSync: Clone { 10 | fn attach(map: StoreHandle) -> Self; 11 | 12 | fn listen bool + 'static + Send + Sync>(&self, _listener: F) { 13 | // Default implementation does nothing. 14 | } 15 | } 16 | 17 | pub trait Aper: AperSync + 'static { 18 | type Intent: Clone + Serialize + for<'de> Deserialize<'de> + PartialEq; 19 | type Error: Debug; 20 | 21 | fn apply( 22 | &mut self, 23 | intent: &Self::Intent, 24 | metadata: &IntentMetadata, 25 | ) -> Result<(), Self::Error>; 26 | 27 | fn suspended_event(&self) -> Option<(Self::Intent, IntentMetadata)> { 28 | None 29 | } 30 | } 31 | 32 | struct SpeculativeIntent { 33 | metadata: IntentMetadata, 34 | intent: I, 35 | version: u64, 36 | } 37 | 38 | pub struct AperClient { 39 | store: Store, 40 | intent_stack: VecDeque>, 41 | 42 | /// The next unused client version number for this client. 43 | next_client_version: u64, 44 | 45 | /// The highest *local* client version that has been confirmed by the server. 46 | verified_client_version: u64, 47 | 48 | /// The highest *server* version that has been confirmed by the server. 49 | /// Note that server and client versions are not related. 50 | verified_server_version: u64, 51 | } 52 | 53 | impl Default for AperClient { 54 | fn default() -> Self { 55 | Self::new() 56 | } 57 | } 58 | 59 | impl AperClient { 60 | pub fn new() -> Self { 61 | let map = Store::default(); 62 | // add an overlay for speculative (local) changes 63 | map.push_overlay(); 64 | 65 | Self { 66 | store: map, 67 | intent_stack: VecDeque::new(), 68 | next_client_version: 1, 69 | verified_client_version: 0, 70 | verified_server_version: 0, 71 | } 72 | } 73 | 74 | pub fn store(&self) -> Store { 75 | self.store.clone() 76 | } 77 | 78 | pub fn connect( 79 | self, 80 | message_callback: F, 81 | ) -> ClientConnection { 82 | ClientConnection::new(self, message_callback) 83 | } 84 | 85 | pub fn state(&self) -> A { 86 | A::attach(self.store.handle()) 87 | } 88 | 89 | pub fn verified_client_version(&self) -> u64 { 90 | self.verified_client_version 91 | } 92 | 93 | pub fn speculative_client_version(&self) -> u64 { 94 | self.intent_stack 95 | .back() 96 | .map_or(self.verified_client_version, |index| index.version) 97 | } 98 | 99 | pub fn verified_server_version(&self) -> u64 { 100 | self.verified_server_version 101 | } 102 | 103 | /// Apply a mutation to the local client state. 104 | pub fn apply( 105 | &mut self, 106 | intent: &A::Intent, 107 | metadata: &IntentMetadata, 108 | ) -> Result { 109 | self.store.push_overlay(); 110 | 111 | { 112 | let mut sm = A::attach(self.store.handle()); 113 | 114 | if let Err(e) = sm.apply(intent, metadata) { 115 | // reverse changes. 116 | self.store.pop_overlay(); 117 | return Err(e); 118 | } 119 | } 120 | 121 | let version = self.next_client_version; 122 | self.intent_stack.push_back(SpeculativeIntent { 123 | intent: intent.clone(), 124 | metadata: metadata.clone(), 125 | version, 126 | }); 127 | self.next_client_version += 1; 128 | 129 | self.store.combine_down(); 130 | self.store.notify_dirty(); 131 | 132 | Ok(version) 133 | } 134 | 135 | /// Mutate the local client state according to server-verified mutations. 136 | pub fn mutate( 137 | &mut self, 138 | mutations: &[Mutation], 139 | client_version: Option, 140 | server_version: u64, 141 | ) { 142 | // pop speculative overlay 143 | // TODO: we need to capture notifications from the speculative overlay being popped, since it could 144 | // undo changes that are not re-done. 145 | self.store.pop_overlay(); 146 | self.verified_server_version = server_version; 147 | 148 | self.store.mutate(mutations); 149 | 150 | // push new speculative overlay 151 | self.store.push_overlay(); 152 | 153 | if let Some(version) = client_version { 154 | self.verified_client_version = version; 155 | 156 | if let Some(index) = self.intent_stack.front() { 157 | if index.version == version { 158 | self.intent_stack.pop_front(); 159 | // happy case; no need to recompute other speculative intents 160 | return; 161 | } 162 | } 163 | 164 | while let Some(index) = self.intent_stack.front() { 165 | if index.version > version { 166 | break; 167 | } 168 | 169 | self.intent_stack.pop_front(); 170 | } 171 | } 172 | 173 | for speculative_intent in self.intent_stack.iter() { 174 | // push a working overlay 175 | self.store.push_overlay(); 176 | 177 | let mut sm = A::attach(self.store.handle()); 178 | 179 | if sm 180 | .apply(&speculative_intent.intent, &speculative_intent.metadata) 181 | .is_err() 182 | { 183 | // reverse changes. 184 | self.store.pop_overlay(); 185 | continue; 186 | } 187 | 188 | self.store.combine_down(); 189 | } 190 | 191 | self.store.notify_dirty(); 192 | } 193 | } 194 | 195 | pub struct AperServer { 196 | map: Store, 197 | version: u64, 198 | _phantom: std::marker::PhantomData, 199 | } 200 | 201 | impl Default for AperServer { 202 | fn default() -> Self { 203 | Self::new() 204 | } 205 | } 206 | 207 | impl AperServer { 208 | pub fn new() -> Self { 209 | let map = Store::default(); 210 | 211 | Self { 212 | map, 213 | version: 0, 214 | _phantom: std::marker::PhantomData, 215 | } 216 | } 217 | 218 | pub fn version(&self) -> u64 { 219 | self.version 220 | } 221 | 222 | pub fn state_snapshot(&self) -> Vec { 223 | // this works because the server only has one layer 224 | self.map.top_layer_mutations() 225 | } 226 | 227 | pub fn apply( 228 | &mut self, 229 | intent: &A::Intent, 230 | metadata: &IntentMetadata, 231 | ) -> Result, A::Error> { 232 | self.map.push_overlay(); 233 | 234 | let mut sm = A::attach(self.map.handle()); 235 | 236 | if let Err(e) = sm.apply(intent, metadata) { 237 | // reverse changes. 238 | self.map.pop_overlay(); 239 | return Err(e); 240 | } 241 | 242 | self.version += 1; 243 | 244 | let mutations = self.map.top_layer_mutations(); 245 | self.map.combine_down(); 246 | 247 | Ok(mutations) 248 | } 249 | 250 | pub fn state(&self) -> A { 251 | A::attach(self.map.handle()) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /aper/src/data_structures/atom.rs: -------------------------------------------------------------------------------- 1 | use crate::{AperSync, StoreHandle}; 2 | use bytes::Bytes; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub struct Atom { 6 | map: StoreHandle, 7 | _phantom: std::marker::PhantomData, 8 | } 9 | 10 | impl Clone for Atom 11 | where 12 | K: Serialize + DeserializeOwned + Default, 13 | { 14 | fn clone(&self) -> Self { 15 | Self { 16 | map: self.map.clone(), 17 | _phantom: std::marker::PhantomData, 18 | } 19 | } 20 | } 21 | 22 | impl AperSync for Atom { 23 | fn attach(map: StoreHandle) -> Self { 24 | Self { 25 | map, 26 | _phantom: std::marker::PhantomData, 27 | } 28 | } 29 | 30 | fn listen bool + 'static + Send + Sync>(&self, listener: F) { 31 | self.map.listen(listener) 32 | } 33 | } 34 | 35 | impl Atom { 36 | pub fn get(&self) -> T { 37 | self.map 38 | .get(&Bytes::new()) 39 | .map(|bytes| bincode::deserialize(&bytes).expect("Couldn't deserialize")) 40 | .unwrap_or_default() 41 | } 42 | 43 | pub fn set(&mut self, value: T) { 44 | self.map.set( 45 | Bytes::new(), 46 | Bytes::from(bincode::serialize(&value).unwrap()), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /aper/src/data_structures/atom_map.rs: -------------------------------------------------------------------------------- 1 | use crate::{AperSync, StoreHandle, StoreIterator}; 2 | use bytes::Bytes; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub struct AtomMap { 6 | map: StoreHandle, 7 | _phantom: std::marker::PhantomData<(K, V)>, 8 | } 9 | 10 | impl Clone for AtomMap 11 | where 12 | K: Serialize + DeserializeOwned, 13 | V: Serialize + DeserializeOwned, 14 | { 15 | fn clone(&self) -> Self { 16 | Self { 17 | map: self.map.clone(), 18 | _phantom: std::marker::PhantomData, 19 | } 20 | } 21 | } 22 | 23 | impl AperSync for AtomMap { 24 | fn attach(map: StoreHandle) -> Self { 25 | Self { 26 | map, 27 | _phantom: std::marker::PhantomData, 28 | } 29 | } 30 | 31 | fn listen bool + 'static + Send + Sync>(&self, listener: F) { 32 | self.map.listen(listener) 33 | } 34 | } 35 | 36 | impl AtomMap { 37 | pub fn get(&self, key: &K) -> Option { 38 | self.map 39 | .get(&Bytes::from(bincode::serialize(key).unwrap())) 40 | .map(|bytes| bincode::deserialize(&bytes).unwrap()) 41 | } 42 | 43 | pub fn set(&mut self, key: &K, value: &V) { 44 | self.map.set( 45 | Bytes::from(bincode::serialize(key).unwrap()), 46 | Bytes::from(bincode::serialize(value).unwrap()), 47 | ); 48 | } 49 | 50 | pub fn delete(&mut self, key: &K) { 51 | self.map 52 | .delete(Bytes::from(bincode::serialize(key).unwrap())); 53 | } 54 | 55 | pub fn iter(&self) -> AtomMapIter { 56 | AtomMapIter { 57 | iter: self.map.iter(), 58 | _phantom: std::marker::PhantomData, 59 | } 60 | } 61 | } 62 | 63 | pub struct AtomMapIter { 64 | iter: StoreIterator, 65 | _phantom: std::marker::PhantomData<(K, V)>, 66 | } 67 | 68 | impl Iterator 69 | for AtomMapIter 70 | { 71 | type Item = (K, V); 72 | 73 | fn next(&mut self) -> Option { 74 | let n = self.iter.next()?; 75 | let key = bincode::deserialize(&n.0).unwrap(); 76 | let value = bincode::deserialize(&n.1).unwrap(); 77 | Some((key, value)) 78 | } 79 | } 80 | 81 | #[cfg(test)] 82 | mod test { 83 | use super::*; 84 | 85 | #[test] 86 | fn atom_map_iter() { 87 | let store = crate::Store::default(); 88 | let mut map = AtomMap::::attach(store.handle()); 89 | 90 | map.set(&"h-insert".to_string(), &"b".to_string()); 91 | map.set(&"a-insert".to_string(), &"a".to_string()); 92 | map.set(&"z-insert".to_string(), &"c".to_string()); 93 | map.set(&"f-insert".to_string(), &"d".to_string()); 94 | 95 | let mut iter = map.iter(); 96 | 97 | assert_eq!(iter.next(), Some(("a-insert".to_string(), "a".to_string()))); 98 | assert_eq!(iter.next(), Some(("f-insert".to_string(), "d".to_string()))); 99 | assert_eq!(iter.next(), Some(("h-insert".to_string(), "b".to_string()))); 100 | assert_eq!(iter.next(), Some(("z-insert".to_string(), "c".to_string()))); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /aper/src/data_structures/fixed_array.rs: -------------------------------------------------------------------------------- 1 | use crate::{AperSync, StoreHandle}; 2 | use bytes::Bytes; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub struct FixedArray { 6 | map: StoreHandle, 7 | _phantom: std::marker::PhantomData, 8 | } 9 | 10 | impl Clone for FixedArray 11 | where 12 | T: Serialize + DeserializeOwned + Default, 13 | { 14 | fn clone(&self) -> Self { 15 | Self { 16 | map: self.map.clone(), 17 | _phantom: std::marker::PhantomData, 18 | } 19 | } 20 | } 21 | 22 | impl AperSync for FixedArray { 23 | fn attach(map: StoreHandle) -> Self { 24 | Self { 25 | map, 26 | _phantom: std::marker::PhantomData, 27 | } 28 | } 29 | 30 | fn listen bool + 'static + Send + Sync>(&self, listener: F) { 31 | self.map.listen(listener) 32 | } 33 | } 34 | 35 | impl FixedArray { 36 | pub fn get(&self, index: u32) -> T { 37 | if let Some(bytes) = self.map.get(&Bytes::from(index.to_be_bytes().to_vec())) { 38 | bincode::deserialize(&bytes).unwrap() 39 | } else { 40 | T::default() 41 | } 42 | } 43 | 44 | pub fn set(&mut self, index: u32, value: T) { 45 | assert!(index < N); 46 | let value = Bytes::from(bincode::serialize(&value).unwrap()); 47 | self.map 48 | .set(Bytes::from(index.to_be_bytes().to_vec()), value); 49 | } 50 | 51 | pub fn iter(&self) -> FixedArrayIterator { 52 | FixedArrayIterator { 53 | tree_ref: self.map.clone(), 54 | index: 0, 55 | stop: N, 56 | _phantom: std::marker::PhantomData, 57 | } 58 | } 59 | } 60 | 61 | pub struct FixedArrayIterator { 62 | tree_ref: StoreHandle, 63 | index: u32, 64 | stop: u32, 65 | _phantom: std::marker::PhantomData, 66 | } 67 | 68 | impl Iterator for FixedArrayIterator { 69 | type Item = T; 70 | 71 | fn next(&mut self) -> Option { 72 | if self.index == self.stop { 73 | return None; 74 | } 75 | 76 | let key = self.index.to_be_bytes().to_vec(); 77 | let value = self.tree_ref.get(&Bytes::from(key)); 78 | self.index += 1; 79 | 80 | Some( 81 | value 82 | .map(|bytes| bincode::deserialize(&bytes).unwrap()) 83 | .unwrap_or_default(), 84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /aper/src/data_structures/map.rs: -------------------------------------------------------------------------------- 1 | use crate::{AperSync, StoreHandle}; 2 | use bytes::Bytes; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | 5 | pub struct Map { 6 | map: StoreHandle, 7 | _phantom: std::marker::PhantomData<(K, V)>, 8 | } 9 | 10 | impl Clone for Map 11 | where 12 | K: Serialize + DeserializeOwned, 13 | V: AperSync, 14 | { 15 | fn clone(&self) -> Self { 16 | Self { 17 | map: self.map.clone(), 18 | _phantom: std::marker::PhantomData, 19 | } 20 | } 21 | } 22 | 23 | impl AperSync for Map { 24 | fn attach(map: StoreHandle) -> Self { 25 | Self { 26 | map, 27 | _phantom: std::marker::PhantomData, 28 | } 29 | } 30 | 31 | fn listen bool + 'static + Send + Sync>(&self, listener: F) { 32 | self.map.listen(listener) 33 | } 34 | } 35 | 36 | impl Map { 37 | pub fn get(&mut self, key: &K) -> Option { 38 | let key = bincode::serialize(key).unwrap(); 39 | Some(V::attach(self.map.child(Bytes::from(key)))) 40 | } 41 | 42 | pub fn get_or_create(&mut self, key: &K) -> V { 43 | let key = bincode::serialize(key).unwrap(); 44 | V::attach(self.map.child(Bytes::from(key))) 45 | } 46 | 47 | pub fn delete(&mut self, key: &K) { 48 | let key = bincode::serialize(key).unwrap(); 49 | self.map.delete_child(Bytes::from(key)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /aper/src/data_structures/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod atom; 2 | pub mod atom_map; 3 | pub mod fixed_array; 4 | pub mod map; 5 | 6 | pub use atom::Atom; 7 | pub use atom_map::AtomMap; 8 | pub use fixed_array::FixedArray; 9 | pub use map::Map; 10 | -------------------------------------------------------------------------------- /aper/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::type_complexity)] 2 | 3 | mod aper; 4 | pub mod connection; 5 | pub mod data_structures; 6 | mod listener; 7 | mod store; 8 | pub use aper::*; 9 | pub use aper_derive::AperSync; 10 | pub use bytes::Bytes; 11 | use chrono::serde::ts_milliseconds; 12 | use chrono::{DateTime, Utc}; 13 | use serde::{Deserialize, Serialize}; 14 | pub use store::*; 15 | 16 | #[derive(Clone, Serialize, Deserialize, Debug)] 17 | pub struct Mutation { 18 | pub prefix: Vec, 19 | pub entries: PrefixMap, 20 | } 21 | 22 | pub type Timestamp = DateTime; 23 | 24 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 25 | pub struct IntentMetadata { 26 | #[serde(with = "ts_milliseconds")] 27 | pub timestamp: Timestamp, 28 | pub client: Option, 29 | } 30 | 31 | impl IntentMetadata { 32 | pub fn new(client: Option, timestamp: Timestamp) -> IntentMetadata { 33 | IntentMetadata { timestamp, client } 34 | } 35 | 36 | pub fn now() -> IntentMetadata { 37 | IntentMetadata::new(None, Utc::now()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /aper/src/listener.rs: -------------------------------------------------------------------------------- 1 | use crate::Bytes; 2 | use std::collections::HashMap; 3 | 4 | // A listener returns `false` if it should be removed. 5 | type Listener = Box bool + Send + Sync>; 6 | 7 | #[derive(Default)] 8 | pub struct ListenerMap { 9 | listeners: HashMap, Vec>, 10 | } 11 | 12 | impl ListenerMap { 13 | pub fn listen bool + 'static + Send + Sync>( 14 | &mut self, 15 | prefix: Vec, 16 | listener: F, 17 | ) { 18 | self.listeners 19 | .entry(prefix) 20 | .or_default() 21 | .push(Box::new(listener)) 22 | } 23 | 24 | pub fn alert(&mut self, prefix: &Vec) { 25 | let Some(listeners) = self.listeners.get_mut(prefix) else { 26 | return; 27 | }; 28 | 29 | listeners.retain(|listener| (listener)()); 30 | 31 | if listeners.is_empty() { 32 | self.listeners.remove(prefix); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /aper/src/store/core.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | handle::StoreHandle, 3 | prefix_map::{PrefixMap, PrefixMapValue}, 4 | }; 5 | use crate::{listener::ListenerMap, Bytes, Mutation}; 6 | use std::{ 7 | collections::{BTreeMap, HashSet}, 8 | sync::{Arc, Mutex, RwLock}, 9 | }; 10 | 11 | #[derive(Default)] 12 | pub struct StoreLayer { 13 | /// Map of prefix to direct children at that prefix. 14 | pub(crate) layer: BTreeMap, PrefixMap>, 15 | /// A set of prefixes that have been modified in this layer. 16 | pub(crate) dirty: HashSet>, 17 | } 18 | 19 | pub struct StoreInner { 20 | pub(crate) layers: RwLock>, 21 | pub(crate) listeners: Mutex, 22 | } 23 | 24 | impl Default for StoreInner { 25 | fn default() -> Self { 26 | Self { 27 | layers: RwLock::new(vec![StoreLayer::default()]), 28 | listeners: Mutex::new(ListenerMap::default()), 29 | } 30 | } 31 | } 32 | 33 | #[derive(Clone, Default)] 34 | pub struct Store { 35 | pub(crate) inner: Arc, 36 | } 37 | 38 | impl Store { 39 | pub fn prefixes(&self) -> Vec> { 40 | let mut result = std::collections::BTreeSet::new(); 41 | let layers = self.inner.layers.read().unwrap(); 42 | 43 | for layer in layers.iter() { 44 | for (prefix, value) in layer.layer.iter() { 45 | match value { 46 | PrefixMap::Children(_) => { 47 | result.insert(prefix.clone()); 48 | } 49 | PrefixMap::DeletedPrefixMap => { 50 | result.remove(prefix); 51 | } 52 | } 53 | } 54 | } 55 | 56 | result.into_iter().collect() 57 | } 58 | 59 | /// Ensure that a prefix exists (even if it is empty) in the store. 60 | pub fn ensure(&self, prefix: &[Bytes]) { 61 | let mut layers = self.inner.layers.write().unwrap(); 62 | let layer = layers.last_mut().unwrap(); 63 | 64 | layer.layer.entry(prefix.to_vec()).or_default(); 65 | } 66 | 67 | pub fn push_overlay(&self) { 68 | let mut layers = self.inner.layers.write().unwrap(); 69 | layers.push(StoreLayer::default()); 70 | } 71 | 72 | pub fn pop_overlay(&self) { 73 | let mut layers = self.inner.layers.write().unwrap(); 74 | layers.pop(); 75 | 76 | if layers.is_empty() { 77 | tracing::error!("popped last overlay"); 78 | } 79 | } 80 | 81 | pub fn notify_dirty(&self) { 82 | let mut dirty_prefixes = HashSet::new(); 83 | 84 | { 85 | // Collect dirty prefixes in an anonymous scope, so that the lock is released before 86 | // listeners are alerted. 87 | let mut layers = self.inner.layers.write().unwrap(); 88 | for layer in layers.iter_mut() { 89 | let new_prefixes = std::mem::take(&mut layer.dirty); 90 | dirty_prefixes.extend(new_prefixes.into_iter()); 91 | } 92 | } 93 | 94 | let mut listeners = self.inner.listeners.lock().unwrap(); 95 | for prefix in dirty_prefixes.iter() { 96 | listeners.alert(prefix); 97 | } 98 | } 99 | 100 | pub fn top_layer_mutations(&self) -> Vec { 101 | let layers = self.inner.layers.read().unwrap(); 102 | let top_layer = layers.last().unwrap(); 103 | 104 | let mut mutations = vec![]; 105 | 106 | for (prefix, entries) in top_layer.layer.iter() { 107 | mutations.push(Mutation { 108 | prefix: prefix.clone(), 109 | entries: entries.clone(), 110 | }); 111 | } 112 | 113 | mutations 114 | } 115 | 116 | pub fn alert(&self, prefix: &Vec) { 117 | let mut listeners = self.inner.listeners.lock().unwrap(); 118 | listeners.alert(prefix); 119 | } 120 | 121 | pub fn combine_down(&self) { 122 | let mut layers = self.inner.layers.write().unwrap(); 123 | 124 | let Some(top_layer) = layers.pop() else { 125 | return; 126 | }; 127 | 128 | // Combine the top layer with the next layer. 129 | let Some(next_layer) = layers.last_mut() else { 130 | return; 131 | }; 132 | 133 | for (prefix, map) in top_layer.layer.iter() { 134 | match map { 135 | PrefixMap::Children(children) => { 136 | let entry = next_layer 137 | .layer 138 | .entry(prefix.clone()) 139 | .or_insert_with(|| PrefixMap::Children(BTreeMap::new())); 140 | 141 | match entry { 142 | PrefixMap::Children(next_children) => { 143 | for (key, value) in children.iter() { 144 | next_children.insert(key.clone(), value.clone()); 145 | } 146 | } 147 | PrefixMap::DeletedPrefixMap => { 148 | next_layer 149 | .layer 150 | .insert(prefix.clone(), PrefixMap::Children(children.clone())); 151 | } 152 | } 153 | } 154 | PrefixMap::DeletedPrefixMap => { 155 | next_layer 156 | .layer 157 | .insert(prefix.clone(), PrefixMap::DeletedPrefixMap); 158 | } 159 | } 160 | } 161 | 162 | next_layer.dirty.extend(top_layer.dirty); 163 | } 164 | 165 | pub fn get(&self, prefix: &Vec, key: &Bytes) -> Option { 166 | let layers = self.inner.layers.read().unwrap(); 167 | 168 | for layer in layers.iter().rev() { 169 | if let Some(map) = layer.layer.get(prefix) { 170 | if let Some(value) = map.get(key) { 171 | match value { 172 | PrefixMapValue::Value(value) => return Some(value.clone()), 173 | PrefixMapValue::Deleted => return None, 174 | } 175 | } 176 | } 177 | } 178 | 179 | None 180 | } 181 | 182 | pub fn mutate(&self, mutations: &[Mutation]) { 183 | let mut layers = self.inner.layers.write().unwrap(); 184 | let top_layer = layers.last_mut().unwrap(); 185 | 186 | for mutation in mutations.iter() { 187 | match &mutation.entries { 188 | PrefixMap::DeletedPrefixMap => { 189 | let map = top_layer.layer.entry(mutation.prefix.clone()).or_default(); 190 | *map = PrefixMap::DeletedPrefixMap; 191 | } 192 | PrefixMap::Children(children) => { 193 | let map = top_layer.layer.entry(mutation.prefix.clone()).or_default(); 194 | 195 | for (key, value) in children.iter() { 196 | map.insert(key.clone(), value.clone()); 197 | } 198 | } 199 | } 200 | 201 | top_layer.dirty.insert(mutation.prefix.clone()); 202 | } 203 | } 204 | 205 | pub fn handle(&self) -> StoreHandle { 206 | StoreHandle::new(self.clone()) 207 | } 208 | } 209 | 210 | #[cfg(test)] 211 | mod test { 212 | use super::*; 213 | 214 | #[test] 215 | fn child_creates_prefix() { 216 | let store = Store::default(); 217 | let mut handle = store.handle(); 218 | 219 | let mut child_handle = handle.child(Bytes::from_static(b"foo")); 220 | let _ = child_handle.child(Bytes::from_static(b"bar")); 221 | 222 | assert_eq!( 223 | store.prefixes(), 224 | vec![ 225 | vec![b"foo".to_vec()], 226 | vec![b"foo".to_vec(), b"bar".to_vec()], 227 | ] 228 | ); 229 | } 230 | 231 | #[test] 232 | fn deleting_parent_deletes_child() { 233 | let store = Store::default(); 234 | let mut handle = store.handle(); 235 | 236 | let mut child_handle = handle.child(Bytes::from_static(b"foo")); 237 | let _ = child_handle.child(Bytes::from_static(b"bar")); 238 | 239 | handle.delete_child(Bytes::from_static(b"foo")); 240 | 241 | assert_eq!(store.prefixes(), vec![] as Vec>); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /aper/src/store/handle.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | core::Store, 3 | iter::StoreIterator, 4 | prefix_map::{PrefixMap, PrefixMapValue}, 5 | }; 6 | use crate::Bytes; 7 | use std::{ 8 | collections::HashSet, 9 | fmt::{Debug, Formatter}, 10 | }; 11 | 12 | #[derive(Clone)] 13 | pub struct StoreHandle { 14 | map: Store, 15 | prefix: Vec, 16 | } 17 | 18 | impl StoreHandle { 19 | pub fn new(map: Store) -> Self { 20 | Self { 21 | map, 22 | prefix: vec![], 23 | } 24 | } 25 | 26 | pub fn listen bool + 'static + Send + Sync>(&self, listener: F) { 27 | let mut listeners = self.map.inner.listeners.lock().unwrap(); 28 | listeners.listen(self.prefix.clone(), listener); 29 | } 30 | 31 | pub fn get(&self, key: &Bytes) -> Option { 32 | self.map.get(&self.prefix, key) 33 | } 34 | 35 | pub fn set(&mut self, key: Bytes, value: Bytes) { 36 | // set the value in the top layer. 37 | 38 | let mut layers = self.map.inner.layers.write().unwrap(); 39 | let top_layer = layers.last_mut().unwrap(); 40 | 41 | let map = top_layer.layer.entry(self.prefix.clone()).or_default(); 42 | 43 | top_layer.dirty.insert(self.prefix.clone()); 44 | 45 | map.insert(key, PrefixMapValue::Value(value)); 46 | } 47 | 48 | pub fn delete(&mut self, key: Bytes) { 49 | // delete the value in the top layer. 50 | 51 | let mut layers = self.map.inner.layers.write().unwrap(); 52 | let top_layer = layers.last_mut().unwrap(); 53 | 54 | let map = top_layer.layer.entry(self.prefix.clone()).or_default(); 55 | 56 | top_layer.dirty.insert(self.prefix.clone()); 57 | 58 | map.insert(key, PrefixMapValue::Deleted); 59 | } 60 | 61 | pub fn child(&mut self, path_part: Bytes) -> Self { 62 | let mut prefix = self.prefix.clone(); 63 | prefix.push(path_part); 64 | self.map.ensure(&prefix); 65 | Self { 66 | map: self.map.clone(), 67 | prefix, 68 | } 69 | } 70 | 71 | pub fn delete_child(&mut self, path_part: Bytes) { 72 | let mut prefix = self.prefix.clone(); 73 | prefix.push(path_part); 74 | 75 | let mut layers = self.map.inner.layers.write().unwrap(); 76 | 77 | // When we delete a prefix, we delete not only that prefix but all of the prefixes under it. 78 | // TODO: This is a bit expensive, in order to make a trade-off that reads are faster. Is the balance optimal? 79 | 80 | let mut prefixes_to_delete = HashSet::new(); 81 | 82 | for layer in layers.iter() { 83 | for (pfx, _) in layer.layer.iter() { 84 | if pfx.starts_with(&prefix) { 85 | prefixes_to_delete.insert(pfx.clone()); 86 | } 87 | } 88 | } 89 | 90 | let top_layer = layers.last_mut().unwrap(); 91 | 92 | for pfx in prefixes_to_delete.iter() { 93 | top_layer 94 | .layer 95 | .insert(pfx.clone(), PrefixMap::DeletedPrefixMap); 96 | top_layer.dirty.insert(pfx.clone()); 97 | } 98 | } 99 | 100 | pub fn iter(&self) -> StoreIterator { 101 | let layers = self.map.inner.layers.read().unwrap(); 102 | 103 | let iter = layers.iter().flat_map(|layer| { 104 | let map = layer.layer.get(&self.prefix)?; 105 | match map { 106 | PrefixMap::Children(map) => Some(map.iter()), 107 | PrefixMap::DeletedPrefixMap => None, 108 | } 109 | }); 110 | 111 | StoreIterator::new(iter) 112 | } 113 | } 114 | 115 | impl Debug for Store { 116 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 117 | let layers = self.inner.layers.read().unwrap(); 118 | 119 | for (i, layer) in layers.iter().enumerate() { 120 | writeln!(f, "Layer {}", i)?; 121 | for (prefix, map) in layer.layer.iter() { 122 | writeln!(f, " {:?} -> {:?}", prefix, map)?; 123 | } 124 | } 125 | 126 | Ok(()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /aper/src/store/iter.rs: -------------------------------------------------------------------------------- 1 | use super::PrefixMapValue; 2 | use crate::Bytes; 3 | use std::collections::btree_map::Iter as BTreeMapIter; 4 | use std::collections::BinaryHeap; 5 | 6 | struct PeekedIterator<'a> { 7 | next_value: (&'a Bytes, &'a PrefixMapValue), 8 | layer_rank: usize, 9 | rest: BTreeMapIter<'a, Bytes, PrefixMapValue>, 10 | } 11 | 12 | impl<'a> PartialEq for PeekedIterator<'a> { 13 | fn eq(&self, _other: &Self) -> bool { 14 | false 15 | } 16 | } 17 | 18 | impl<'a> PartialOrd for PeekedIterator<'a> { 19 | fn partial_cmp(&self, other: &Self) -> Option { 20 | Some(self.cmp(other)) 21 | } 22 | } 23 | 24 | impl<'a> Eq for PeekedIterator<'a> {} 25 | 26 | impl<'a> Ord for PeekedIterator<'a> { 27 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 28 | println!("self: {:?}, other: {:?}", self.next_value, other.next_value); 29 | let result = 30 | (self.next_value.0, self.layer_rank).cmp(&(other.next_value.0, other.layer_rank)); 31 | println!("result: {:?}", result); 32 | result 33 | } 34 | } 35 | 36 | pub struct StoreIterator { 37 | inner: Vec<(Bytes, Bytes)>, 38 | } 39 | 40 | impl StoreIterator { 41 | pub fn new<'a>(iter: impl Iterator>) -> Self { 42 | let mut inner = Vec::new(); 43 | 44 | let mut heap = BinaryHeap::new(); 45 | for (layer_rank, mut iter) in iter.enumerate() { 46 | let next_value = iter.next_back(); 47 | if let Some((key, value)) = next_value { 48 | println!("pushing... {:?}", key); 49 | heap.push(PeekedIterator { 50 | next_value: (key, value), 51 | layer_rank, 52 | rest: iter, 53 | }); 54 | } 55 | } 56 | 57 | let mut last_key: Option = None; 58 | while let Some(mut peeked) = heap.pop() { 59 | println!("aa {:?}", peeked.next_value.0); 60 | 61 | if last_key.as_ref() == Some(peeked.next_value.0) { 62 | // we have already encountered this key; skip it. 63 | continue; 64 | } 65 | 66 | match peeked.next_value { 67 | (key, PrefixMapValue::Value(value)) => { 68 | inner.push((key.clone(), value.clone())); 69 | } 70 | (_key, PrefixMapValue::Deleted) => {} 71 | } 72 | 73 | last_key = Some(peeked.next_value.0.clone()); 74 | 75 | let next_value = peeked.rest.next_back(); 76 | if let Some(next_value) = next_value { 77 | heap.push(PeekedIterator { 78 | next_value, 79 | layer_rank: peeked.layer_rank, 80 | rest: peeked.rest, 81 | }); 82 | } 83 | } 84 | 85 | Self { inner } 86 | } 87 | } 88 | 89 | impl Iterator for StoreIterator { 90 | type Item = (Bytes, Bytes); 91 | 92 | fn next(&mut self) -> Option { 93 | self.inner.pop() 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod test { 99 | use super::*; 100 | use std::collections::BTreeMap; 101 | 102 | #[test] 103 | fn no_layers() { 104 | let iter_inner = StoreIterator::new(Vec::new().into_iter()); 105 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 106 | assert_eq!(d, Vec::new()); 107 | } 108 | 109 | #[test] 110 | fn multiple_empty_layers() { 111 | let v1 = BTreeMap::new(); 112 | let v2 = BTreeMap::new(); 113 | let iter_inner = StoreIterator::new(vec![v1.iter(), v2.iter()].into_iter()); 114 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 115 | assert_eq!(d, Vec::new()); 116 | } 117 | 118 | #[test] 119 | fn one_nonempty_layer() { 120 | let mut v1 = BTreeMap::new(); 121 | 122 | v1.insert( 123 | Bytes::from("key1"), 124 | PrefixMapValue::Value(Bytes::from("abc")), 125 | ); 126 | 127 | let iter_inner = StoreIterator::new(vec![v1.iter()].into_iter()); 128 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 129 | assert_eq!(d, vec![(Bytes::from("key1"), Bytes::from("abc")),]); 130 | } 131 | 132 | #[test] 133 | fn two_nonempty_layers_no_overlap() { 134 | let mut v1 = BTreeMap::new(); 135 | 136 | v1.insert( 137 | Bytes::from("key1"), 138 | PrefixMapValue::Value(Bytes::from("abc")), 139 | ); 140 | v1.insert( 141 | Bytes::from("key5"), 142 | PrefixMapValue::Value(Bytes::from("abc")), 143 | ); 144 | 145 | let mut v2 = BTreeMap::new(); 146 | 147 | v2.insert( 148 | Bytes::from("key3"), 149 | PrefixMapValue::Value(Bytes::from("abc")), 150 | ); 151 | 152 | let iter_inner = StoreIterator::new(vec![v1.iter(), v2.iter()].into_iter()); 153 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 154 | assert_eq!( 155 | d, 156 | vec![ 157 | (Bytes::from("key1"), Bytes::from("abc")), 158 | (Bytes::from("key3"), Bytes::from("abc")), 159 | (Bytes::from("key5"), Bytes::from("abc")), 160 | ] 161 | ); 162 | } 163 | 164 | #[test] 165 | fn two_nonempty_layers_overlap() { 166 | let mut v1 = BTreeMap::new(); 167 | 168 | v1.insert( 169 | Bytes::from("overlapping-key"), 170 | PrefixMapValue::Value(Bytes::from("erased value")), 171 | ); 172 | 173 | let mut v2 = BTreeMap::new(); 174 | 175 | v2.insert( 176 | Bytes::from("overlapping-key"), 177 | PrefixMapValue::Value(Bytes::from("intended value")), 178 | ); 179 | 180 | let iter_inner = StoreIterator::new(vec![v1.iter(), v2.iter()].into_iter()); 181 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 182 | assert_eq!( 183 | d, 184 | vec![( 185 | Bytes::from("overlapping-key"), 186 | Bytes::from("intended value") 187 | ),] 188 | ); 189 | } 190 | 191 | #[test] 192 | fn two_nonempty_layers_deletion() { 193 | let mut v1 = BTreeMap::new(); 194 | 195 | v1.insert( 196 | Bytes::from("deleted-key"), 197 | PrefixMapValue::Value(Bytes::from("erased value")), 198 | ); 199 | 200 | let mut v2 = BTreeMap::new(); 201 | 202 | v2.insert(Bytes::from("deleted-key"), PrefixMapValue::Deleted); 203 | 204 | let iter_inner = StoreIterator::new(vec![v1.iter(), v2.iter()].into_iter()); 205 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 206 | assert_eq!(d, vec![]); 207 | } 208 | 209 | #[test] 210 | fn undeleted_key() { 211 | let mut v1 = BTreeMap::new(); 212 | v1.insert( 213 | Bytes::from("deleted-key"), 214 | PrefixMapValue::Value(Bytes::from("erased value")), 215 | ); 216 | 217 | let mut v2 = BTreeMap::new(); 218 | v2.insert(Bytes::from("deleted-key"), PrefixMapValue::Deleted); 219 | 220 | let mut v3 = BTreeMap::new(); 221 | v3.insert( 222 | Bytes::from("deleted-key"), 223 | PrefixMapValue::Value(Bytes::from("recreated value")), 224 | ); 225 | 226 | let iter_inner = StoreIterator::new(vec![v1.iter(), v2.iter(), v3.iter()].into_iter()); 227 | let d: Vec<(Bytes, Bytes)> = iter_inner.collect(); 228 | assert_eq!( 229 | d, 230 | vec![(Bytes::from("deleted-key"), Bytes::from("recreated value")),] 231 | ); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /aper/src/store/mod.rs: -------------------------------------------------------------------------------- 1 | mod core; 2 | mod handle; 3 | mod iter; 4 | mod prefix_map; 5 | 6 | pub use core::Store; 7 | pub use handle::StoreHandle; 8 | pub use iter::StoreIterator; 9 | pub use prefix_map::{PrefixMap, PrefixMapValue}; 10 | -------------------------------------------------------------------------------- /aper/src/store/prefix_map.rs: -------------------------------------------------------------------------------- 1 | use crate::Bytes; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{collections::BTreeMap, fmt::Debug}; 4 | 5 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, PartialOrd, Eq, Ord)] 6 | pub enum PrefixMapValue { 7 | Value(Bytes), 8 | Deleted, 9 | } 10 | 11 | #[derive(Clone, Serialize, Deserialize, Debug)] 12 | pub enum PrefixMap { 13 | Children(BTreeMap), 14 | DeletedPrefixMap, 15 | } 16 | 17 | impl PrefixMap { 18 | pub fn get(&self, key: &Bytes) -> Option { 19 | match self { 20 | PrefixMap::Children(children) => children.get(key).cloned(), 21 | PrefixMap::DeletedPrefixMap => Some(PrefixMapValue::Deleted), 22 | } 23 | } 24 | 25 | pub fn insert(&mut self, key: Bytes, value: PrefixMapValue) { 26 | match self { 27 | PrefixMap::Children(children) => { 28 | children.insert(key, value); 29 | } 30 | PrefixMap::DeletedPrefixMap => { 31 | if value == PrefixMapValue::Deleted { 32 | // the prefix map is deleted, so we don't need to delete the value. 33 | return; 34 | } 35 | 36 | let mut new_children = BTreeMap::new(); 37 | new_children.insert(key, value); 38 | *self = PrefixMap::Children(new_children); 39 | } 40 | } 41 | } 42 | } 43 | 44 | impl Default for PrefixMap { 45 | fn default() -> Self { 46 | Self::Children(BTreeMap::new()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /aper/tests/backtrack.rs: -------------------------------------------------------------------------------- 1 | use aper::{data_structures::atom_map::AtomMap, AperSync, Store}; 2 | 3 | #[test] 4 | fn test_backtrack() { 5 | let store = Store::default(); 6 | let mut map = AtomMap::::attach(store.handle()); 7 | 8 | { 9 | map.set(&1, &2); 10 | map.set(&3, &4); 11 | 12 | assert_eq!(map.get(&1), Some(2)); 13 | assert_eq!(map.get(&3), Some(4)); 14 | } 15 | 16 | // add an overlay to the map 17 | 18 | { 19 | store.push_overlay(); 20 | 21 | // existing values are still there 22 | 23 | assert_eq!(map.get(&1), Some(2)); 24 | assert_eq!(map.get(&3), Some(4)); 25 | 26 | // new values can be added 27 | 28 | map.set(&5, &6); 29 | map.set(&7, &8); 30 | 31 | assert_eq!(map.get(&5), Some(6)); 32 | assert_eq!(map.get(&7), Some(8)); 33 | 34 | // existing values can be updated 35 | 36 | map.set(&1, &10); 37 | map.set(&3, &12); 38 | 39 | assert_eq!(map.get(&1), Some(10)); 40 | assert_eq!(map.get(&3), Some(12)); 41 | 42 | // existing values can be removed 43 | 44 | map.delete(&1); 45 | map.delete(&3); 46 | 47 | assert_eq!(map.get(&1), None); 48 | assert_eq!(map.get(&3), None); 49 | } 50 | 51 | // when we pop the overlay, the original values are restored 52 | 53 | { 54 | store.pop_overlay(); 55 | 56 | assert_eq!(map.get(&1), Some(2)); 57 | assert_eq!(map.get(&3), Some(4)); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /aper/tests/listener.rs: -------------------------------------------------------------------------------- 1 | use aper::{ 2 | data_structures::{atom::Atom, fixed_array::FixedArray}, 3 | Aper, AperClient, AperSync, Bytes, IntentMetadata, Mutation, PrefixMap, PrefixMapValue, 4 | }; 5 | use chrono::Utc; 6 | use serde::{Deserialize, Serialize}; 7 | use std::{collections::BTreeMap, sync::mpsc::channel}; 8 | 9 | #[derive(AperSync, Clone)] 10 | struct SimpleStruct { 11 | atom_i32: Atom, 12 | atom_string: Atom, 13 | fixed_array: FixedArray<5, u8>, 14 | } 15 | 16 | #[derive(Clone, PartialEq, Serialize, Deserialize)] 17 | pub enum SimpleIntent { 18 | SetAtomI32(i32), 19 | SetAtomString(String), 20 | SetFixedArray(u32, u8), 21 | } 22 | 23 | impl Aper for SimpleStruct { 24 | type Intent = SimpleIntent; 25 | type Error = (); 26 | 27 | fn apply( 28 | &mut self, 29 | intent: &Self::Intent, 30 | _metadata: &IntentMetadata, 31 | ) -> Result<(), Self::Error> { 32 | match intent { 33 | SimpleIntent::SetAtomI32(value) => self.atom_i32.set(*value), 34 | SimpleIntent::SetAtomString(value) => self.atom_string.set(value.clone()), 35 | SimpleIntent::SetFixedArray(index, value) => self.fixed_array.set(*index, *value), 36 | } 37 | 38 | Ok(()) 39 | } 40 | } 41 | 42 | fn create_mutation(prefix: Vec<&[u8]>, entries: Vec<(Vec, PrefixMapValue)>) -> Mutation { 43 | let entries = entries 44 | .into_iter() 45 | .map(|(k, v)| (Bytes::from(k.to_vec()), v)) 46 | .collect::>(); 47 | let entries = PrefixMap::Children(entries); 48 | let prefix = prefix.iter().map(|x| Bytes::from(x.to_vec())).collect(); 49 | Mutation { prefix, entries } 50 | } 51 | 52 | #[test] 53 | fn test_apply_listener() { 54 | let mut client: AperClient = aper::AperClient::new(); 55 | 56 | let (atom_i32_send, atom_i32_recv) = channel(); 57 | let (atom_string_send, atom_string_recv) = channel(); 58 | let (fixed_array_send, fixed_array_recv) = channel(); 59 | 60 | let st = client.state(); 61 | 62 | st.atom_i32.listen(move || atom_i32_send.send(()).is_ok()); 63 | st.atom_string 64 | .listen(move || atom_string_send.send(()).is_ok()); 65 | st.fixed_array 66 | .listen(move || fixed_array_send.send(()).is_ok()); 67 | 68 | client 69 | .apply( 70 | &SimpleIntent::SetAtomI32(42), 71 | &IntentMetadata::new(None, Utc::now()), 72 | ) 73 | .unwrap(); 74 | 75 | assert!(atom_i32_recv.try_recv().is_ok()); 76 | assert!(atom_string_recv.try_recv().is_err()); 77 | assert!(fixed_array_recv.try_recv().is_err()); 78 | 79 | client 80 | .apply( 81 | &SimpleIntent::SetAtomString("hello".to_string()), 82 | &IntentMetadata::new(None, Utc::now()), 83 | ) 84 | .unwrap(); 85 | 86 | assert!(atom_i32_recv.try_recv().is_err()); 87 | assert!(atom_string_recv.try_recv().is_ok()); 88 | assert!(fixed_array_recv.try_recv().is_err()); 89 | 90 | client 91 | .apply( 92 | &SimpleIntent::SetFixedArray(0, 42), 93 | &IntentMetadata::new(None, Utc::now()), 94 | ) 95 | .unwrap(); 96 | 97 | assert!(atom_i32_recv.try_recv().is_err()); 98 | assert!(atom_string_recv.try_recv().is_err()); 99 | assert!(fixed_array_recv.try_recv().is_ok()); 100 | } 101 | 102 | #[test] 103 | fn test_mutate_listener_simple() { 104 | // simple case: server mutates a value directly 105 | 106 | let mut client: AperClient = aper::AperClient::new(); 107 | 108 | let (atom_i32_send, atom_i32_recv) = channel(); 109 | let (atom_string_send, atom_string_recv) = channel(); 110 | let (fixed_array_send, fixed_array_recv) = channel(); 111 | 112 | let st = client.state(); 113 | 114 | st.atom_i32.listen(move || atom_i32_send.send(()).is_ok()); 115 | st.atom_string 116 | .listen(move || atom_string_send.send(()).is_ok()); 117 | st.fixed_array 118 | .listen(move || fixed_array_send.send(()).is_ok()); 119 | 120 | client.mutate( 121 | &[create_mutation( 122 | vec![b"atom_i32"], 123 | vec![( 124 | b"".to_vec(), 125 | PrefixMapValue::Value(Bytes::from(42i32.to_le_bytes().to_vec())), 126 | )], 127 | )], 128 | None, 129 | 1, 130 | ); 131 | 132 | assert_eq!(42, st.atom_i32.get()); 133 | 134 | assert!(atom_i32_recv.try_recv().is_ok()); 135 | assert!(atom_string_recv.try_recv().is_err()); 136 | assert!(fixed_array_recv.try_recv().is_err()); 137 | } 138 | 139 | #[derive(AperSync, Clone)] 140 | struct LinkedFields { 141 | lhs: Atom, 142 | rhs: Atom, 143 | sum: Atom, 144 | } 145 | 146 | #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] 147 | pub enum LinkedFieldIntent { 148 | SetLhs(i32), 149 | SetRhs(i32), 150 | } 151 | 152 | impl Aper for LinkedFields { 153 | type Intent = LinkedFieldIntent; 154 | type Error = (); 155 | 156 | fn apply( 157 | &mut self, 158 | intent: &Self::Intent, 159 | _metadata: &IntentMetadata, 160 | ) -> Result<(), Self::Error> { 161 | match intent { 162 | LinkedFieldIntent::SetLhs(value) => self.lhs.set(*value), 163 | LinkedFieldIntent::SetRhs(value) => self.rhs.set(*value), 164 | } 165 | 166 | self.sum.set(self.lhs.get() + self.rhs.get()); 167 | 168 | Ok(()) 169 | } 170 | } 171 | 172 | #[test] 173 | fn test_mutate_listener_incidental() { 174 | // more complex case: server mutation causes another value to be recomputed 175 | 176 | let mut client: AperClient = aper::AperClient::new(); 177 | 178 | let (lhs_send, lhs_recv) = channel(); 179 | let (rhs_send, rhs_recv) = channel(); 180 | let (sum_send, sum_recv) = channel(); 181 | 182 | let st = client.state(); 183 | 184 | st.lhs.listen(move || lhs_send.send(()).is_ok()); 185 | st.rhs.listen(move || rhs_send.send(()).is_ok()); 186 | st.sum.listen(move || sum_send.send(()).is_ok()); 187 | 188 | client 189 | .apply( 190 | &LinkedFieldIntent::SetLhs(1), 191 | &IntentMetadata::new(None, Utc::now()), 192 | ) 193 | .unwrap(); 194 | 195 | assert_eq!(1, st.lhs.get()); 196 | assert_eq!(1, st.sum.get()); 197 | 198 | client.mutate(&[], None, 1); 199 | 200 | assert!(lhs_recv.try_recv().is_ok()); 201 | assert!(rhs_recv.try_recv().is_err()); 202 | assert!(sum_recv.try_recv().is_ok()); 203 | 204 | // now mutate the rhs, which should cause the sum to be recomputed 205 | 206 | client.mutate( 207 | &[create_mutation( 208 | vec![b"rhs"], 209 | vec![( 210 | b"".to_vec(), 211 | PrefixMapValue::Value(Bytes::from(26i32.to_le_bytes().to_vec())), 212 | )], 213 | )], 214 | None, 215 | 1, 216 | ); 217 | 218 | assert_eq!(26, st.rhs.get()); 219 | assert_eq!(27, st.sum.get()); 220 | 221 | // note: the underlying value of lhs did not change, 222 | // so we could omit it in the future as an optimization. 223 | assert!(lhs_recv.try_recv().is_ok()); 224 | assert!(rhs_recv.try_recv().is_ok()); 225 | assert!(sum_recv.try_recv().is_ok()); 226 | } 227 | -------------------------------------------------------------------------------- /aper/tests/lock.rs: -------------------------------------------------------------------------------- 1 | //! Listener functions should be able to access document data if they hold a handle to an `AperSync` object. 2 | //! A naive implementation where we lock the entire store doesn't work, because locking to iterate over 3 | //! listeners breaks the ability to obtain a lock to access the store. 4 | 5 | use aper::{data_structures::Atom, AperSync, Store}; 6 | 7 | #[test] 8 | fn listener_can_access_data() { 9 | let (tx, rx) = std::sync::mpsc::channel::(); 10 | let store = Store::default(); 11 | 12 | let mut atom1: Atom = Atom::attach(store.handle()); 13 | let atom2: Atom = Atom::attach(store.handle()); 14 | 15 | atom1.listen(move || { 16 | tx.send(atom2.get()).unwrap(); 17 | true 18 | }); 19 | 20 | atom1.set(42); 21 | store.alert(&vec![]); 22 | 23 | assert_eq!(rx.try_recv().unwrap(), 42); 24 | } 25 | -------------------------------------------------------------------------------- /aper/tests/simple-client-server.rs: -------------------------------------------------------------------------------- 1 | use aper::{ 2 | data_structures::atom::Atom, Aper, AperClient, AperServer, AperSync, IntentMetadata, 3 | StoreHandle, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Clone)] 8 | struct Counter(Atom); 9 | 10 | impl AperSync for Counter { 11 | fn attach(map: StoreHandle) -> Self { 12 | Self(Atom::attach(map)) 13 | } 14 | } 15 | 16 | impl Counter { 17 | fn get(&self) -> u64 { 18 | self.0.get() 19 | } 20 | } 21 | 22 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 23 | enum CounterIntent { 24 | IncrementBy(u64), 25 | SetTo(u64), 26 | } 27 | 28 | impl Aper for Counter { 29 | type Intent = CounterIntent; 30 | type Error = (); 31 | 32 | fn apply( 33 | &mut self, 34 | intent: &Self::Intent, 35 | _metadata: &IntentMetadata, 36 | ) -> Result<(), Self::Error> { 37 | match &intent { 38 | CounterIntent::IncrementBy(amount) => { 39 | self.0.set(self.0.get() + amount); 40 | } 41 | CounterIntent::SetTo(value) => { 42 | self.0.set(*value); 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | } 49 | 50 | #[test] 51 | fn test_local_change() { 52 | let mut client = AperClient::::new(); 53 | let mut server = AperServer::::new(); 54 | 55 | let version = client 56 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 57 | .unwrap(); 58 | 59 | assert_eq!(1, version); 60 | assert_eq!(0, client.verified_client_version()); 61 | assert_eq!(1, client.speculative_client_version()); 62 | 63 | let mutations = server 64 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 65 | .unwrap(); 66 | 67 | client.mutate(&mutations, Some(version), 1); 68 | 69 | assert_eq!(1, client.verified_client_version()); 70 | assert_eq!(1, client.speculative_client_version()); 71 | 72 | let state = client.state(); 73 | assert_eq!(5, state.get()); 74 | } 75 | 76 | #[test] 77 | fn test_remote_change() { 78 | let mut server = AperServer::::new(); 79 | 80 | let mutations = server 81 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 82 | .unwrap(); 83 | 84 | let mut client = AperClient::::new(); 85 | client.mutate(&mutations, None, 1); 86 | 87 | assert_eq!(0, client.verified_client_version()); 88 | assert_eq!(0, client.speculative_client_version()); 89 | 90 | let state = client.state(); 91 | assert_eq!(5, state.get()); 92 | } 93 | 94 | #[test] 95 | fn test_speculative_change_remains() { 96 | // client1 makes a speculative change, then receives another change from the server. 97 | // client1 should apply both the speculative change and the server change. 98 | 99 | let mut server = AperServer::::new(); 100 | let mut client = AperClient::::new(); 101 | 102 | client 103 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 104 | .unwrap(); 105 | 106 | let mutations = server 107 | .apply(&CounterIntent::IncrementBy(10), &IntentMetadata::now()) 108 | .unwrap(); 109 | 110 | client.mutate(&mutations, None, 1); 111 | 112 | assert_eq!(0, client.verified_client_version()); 113 | assert_eq!(1, client.speculative_client_version()); 114 | 115 | let state = client.state(); 116 | assert_eq!(15, state.get()); 117 | } 118 | 119 | #[test] 120 | fn test_remote_changes_persist() { 121 | let mut server = AperServer::::new(); 122 | let mut client = AperClient::::new(); 123 | 124 | let mutations = server 125 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 126 | .unwrap(); 127 | client.mutate(&mutations, None, 1); 128 | 129 | let state = client.state(); 130 | assert_eq!(5, state.get()); 131 | 132 | let mutations = server 133 | .apply(&CounterIntent::IncrementBy(5), &IntentMetadata::now()) 134 | .unwrap(); 135 | client.mutate(&mutations, None, 1); 136 | 137 | let state = client.state(); 138 | assert_eq!(10, state.get()); 139 | } 140 | -------------------------------------------------------------------------------- /aper/tests/store-cleanup.rs: -------------------------------------------------------------------------------- 1 | use aper::{ 2 | data_structures::{FixedArray, Map}, 3 | AperSync, 4 | }; 5 | 6 | // A Rust macro that takes a list of values and returns a Vec that is constructed from bincode-serializing each value. 7 | macro_rules! prefix { 8 | ($($x:expr),*) => { 9 | vec![$(bincode::serialize(&$x).unwrap()),*] 10 | }; 11 | } 12 | 13 | #[test] 14 | fn test_store_cleanup() { 15 | let store = aper::Store::default(); 16 | let mut map = Map::>::attach(store.handle()); 17 | 18 | map.get_or_create(&"key1".to_string()); 19 | 20 | { 21 | let prefixes = store.prefixes(); 22 | assert_eq!(prefixes, vec![prefix!("key1".to_string()),]); 23 | } 24 | 25 | map.delete(&"key1".to_string()); 26 | 27 | // TODO: Enable this. 28 | 29 | // { 30 | // let prefixes = store.prefixes(); 31 | // assert!(prefixes.is_empty()); 32 | // } 33 | } 34 | -------------------------------------------------------------------------------- /aper/tests/tic-tac-toe.rs: -------------------------------------------------------------------------------- 1 | use aper::{ 2 | data_structures::{atom::Atom, fixed_array::FixedArray}, 3 | Aper, AperSync, IntentMetadata, Store, 4 | }; 5 | use chrono::Utc; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(AperSync, Clone)] 9 | struct TicTacToe { 10 | grid: FixedArray<9, Option>, 11 | player: Atom, 12 | winner: Atom>, 13 | } 14 | 15 | #[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq)] 16 | enum TicTacToePlay { 17 | Play(u8), 18 | Reset, 19 | } 20 | 21 | #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] 22 | enum TicTacToePlayer { 23 | #[default] 24 | X, 25 | O, 26 | } 27 | 28 | fn check_winner(grid: Vec>) -> Option { 29 | let winning_combinations = vec![ 30 | vec![0, 1, 2], 31 | vec![3, 4, 5], 32 | vec![6, 7, 8], 33 | vec![0, 3, 6], 34 | vec![1, 4, 7], 35 | vec![2, 5, 8], 36 | vec![0, 4, 8], 37 | vec![2, 4, 6], 38 | ]; 39 | 40 | for combination in winning_combinations { 41 | let player = grid[combination[0]]; 42 | 43 | if player.is_none() { 44 | continue; 45 | } 46 | 47 | if grid[combination[1]] == player && grid[combination[2]] == player { 48 | return player; 49 | } 50 | } 51 | 52 | None 53 | } 54 | 55 | impl Aper for TicTacToe { 56 | type Intent = TicTacToePlay; 57 | type Error = (); 58 | 59 | fn apply( 60 | &mut self, 61 | intent: &Self::Intent, 62 | _metadata: &IntentMetadata, 63 | ) -> Result<(), Self::Error> { 64 | let player = self.player.get(); 65 | 66 | match &intent { 67 | TicTacToePlay::Play(cell) => { 68 | self.grid.set(*cell as u32, Some(player)); 69 | self.player.set(match player { 70 | TicTacToePlayer::X => TicTacToePlayer::O, 71 | TicTacToePlayer::O => TicTacToePlayer::X, 72 | }); 73 | 74 | // Check for win 75 | 76 | let grid: Vec> = self.grid.iter().collect(); 77 | if let Some(winner) = check_winner(grid) { 78 | self.winner.set(Some(winner)); 79 | } 80 | } 81 | TicTacToePlay::Reset => { 82 | for i in 0..9 { 83 | self.grid.set(i, None); 84 | } 85 | } 86 | } 87 | 88 | Ok(()) 89 | } 90 | } 91 | 92 | #[test] 93 | fn test_tic_tac_toe() { 94 | let map = Store::default(); 95 | let mut game = TicTacToe::attach(map.handle()); 96 | 97 | game.apply( 98 | &TicTacToePlay::Play(0), 99 | &IntentMetadata::new(None, Utc::now()), 100 | ) 101 | .unwrap(); // X 102 | game.apply( 103 | &TicTacToePlay::Play(1), 104 | &IntentMetadata::new(None, Utc::now()), 105 | ) 106 | .unwrap(); // O 107 | game.apply( 108 | &TicTacToePlay::Play(3), 109 | &IntentMetadata::new(None, Utc::now()), 110 | ) 111 | .unwrap(); // X 112 | game.apply( 113 | &TicTacToePlay::Play(2), 114 | &IntentMetadata::new(None, Utc::now()), 115 | ) 116 | .unwrap(); // O 117 | 118 | assert_eq!(game.grid.get(0), Some(TicTacToePlayer::X)); 119 | assert_eq!(game.grid.get(1), Some(TicTacToePlayer::O)); 120 | assert_eq!(game.grid.get(3), Some(TicTacToePlayer::X)); 121 | assert_eq!(game.grid.get(2), Some(TicTacToePlayer::O)); 122 | 123 | assert_eq!(game.winner.get(), None); 124 | 125 | game.apply( 126 | &TicTacToePlay::Play(6), 127 | &IntentMetadata::new(None, Utc::now()), 128 | ) 129 | .unwrap(); // X for the win 130 | assert_eq!(game.winner.get(), Some(TicTacToePlayer::X)); 131 | 132 | game.apply(&TicTacToePlay::Reset, &IntentMetadata::now()) 133 | .unwrap(); 134 | 135 | for i in 0..9 { 136 | assert_eq!(game.grid.get(i), None); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | out.wasm 2 | out.wat 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Examples use [Stateroom](https://github.com/drifting-in-space/stateroom), which builds and serves both the client and server components of the code. 2 | 3 | Install Stateroom by running `cargo install stateroom`, and then run `stateroom dev` in the base of any of the examples. 4 | -------------------------------------------------------------------------------- /examples/counter-leptos/.gitignore: -------------------------------------------------------------------------------- 1 | static-client 2 | client-pkg -------------------------------------------------------------------------------- /examples/counter-leptos/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "client", 5 | "common", 6 | "service", 7 | ] -------------------------------------------------------------------------------- /examples/counter-leptos/client/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" 3 | -------------------------------------------------------------------------------- /examples/counter-leptos/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-client" 3 | version = "0.1.0" 4 | authors = ["Paul Butler "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [dependencies] 11 | aper = {path = "../../../aper"} 12 | aper-leptos = { version="0.5.0", path="../../../aper-leptos" } 13 | aper-stateroom = { version="0.5.0", path="../../../aper-stateroom" } 14 | aper-websocket-client = { version="0.5.0", path="../../../aper-websocket-client" } 15 | console_error_panic_hook = "0.1.6" 16 | counter-common = {path="../common"} 17 | leptos = { version = "0.6.14", features = ["csr"] } 18 | serde = { version = "1.0.124", features = ["derive"] } 19 | tracing = "0.1.40" 20 | tracing-subscriber = "0.3.18" 21 | tracing-web = "0.1.3" 22 | wasm-bindgen = "0.2.83" 23 | web-sys = "0.3.48" 24 | -------------------------------------------------------------------------------- /examples/counter-leptos/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper_websocket_client::AperWebSocketClient; 2 | use aper_leptos::{init_tracing, Watch}; 3 | pub use counter_common::{Counter, CounterIntent}; 4 | use leptos::{component, view, IntoView}; 5 | use wasm_bindgen::prelude::*; 6 | 7 | #[component] 8 | fn App() -> impl IntoView { 9 | let url = "ws://localhost:8080/ws"; 10 | 11 | let client_program = AperWebSocketClient::::new(url).unwrap(); 12 | 13 | let state = client_program.state(); 14 | 15 | view! { 16 | 26 | } 27 | } 28 | 29 | #[wasm_bindgen(start)] 30 | pub fn entry() { 31 | init_tracing::init_tracing(); 32 | 33 | leptos::mount_to_body(|| view! { }) 34 | } 35 | -------------------------------------------------------------------------------- /examples/counter-leptos/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-common" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | aper = {path="../../../aper"} 8 | serde = { version = "1.0.127", features = ["derive"] } 9 | -------------------------------------------------------------------------------- /examples/counter-leptos/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::{data_structures::atom::Atom, Aper, AperSync, IntentMetadata}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(AperSync, Clone)] 5 | pub struct Counter { 6 | pub value: Atom, 7 | } 8 | 9 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 10 | pub enum CounterIntent { 11 | Add(i64), 12 | Subtract(i64), 13 | Reset, 14 | } 15 | 16 | impl Counter { 17 | pub fn value(&self) -> i64 { 18 | self.value.get() 19 | } 20 | } 21 | 22 | impl Aper for Counter { 23 | type Intent = CounterIntent; 24 | type Error = (); 25 | 26 | fn apply(&mut self, intent: &CounterIntent, _metadata: &IntentMetadata) -> Result<(), ()> { 27 | let value = self.value.get(); 28 | 29 | match &intent { 30 | CounterIntent::Add(i) => { 31 | self.value.set(value + i); 32 | } 33 | CounterIntent::Subtract(i) => { 34 | self.value.set(value - i); 35 | } 36 | CounterIntent::Reset => { 37 | self.value.set(0); 38 | } 39 | } 40 | 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/counter-leptos/service/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-wasi" 3 | -------------------------------------------------------------------------------- /examples/counter-leptos/service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-service" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | aper-stateroom = { path="../../../aper-stateroom" } 11 | aper = { path="../../../aper" } 12 | stateroom-wasm = { version="0.4.0" } 13 | counter-common = { path="../common" } 14 | -------------------------------------------------------------------------------- /examples/counter-leptos/service/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper_stateroom::AperStateroomService; 2 | use counter_common::Counter; 3 | use stateroom_wasm::stateroom_wasm; 4 | 5 | #[stateroom_wasm] 6 | type CounterService = AperStateroomService; 7 | -------------------------------------------------------------------------------- /examples/counter-leptos/stateroom.toml: -------------------------------------------------------------------------------- 1 | static_files = "static" 2 | 3 | [client] 4 | package = "counter-client" 5 | 6 | [service] 7 | package = "counter-service" 8 | -------------------------------------------------------------------------------- /examples/counter-leptos/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Aper Project 6 | 7 | 8 | 17 | 18 | 19 |

20 | 21 | -------------------------------------------------------------------------------- /examples/counter/.gitignore: -------------------------------------------------------------------------------- 1 | static-client 2 | client-pkg -------------------------------------------------------------------------------- /examples/counter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "client", 5 | "common", 6 | "service", 7 | ] 8 | -------------------------------------------------------------------------------- /examples/counter/client/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" 3 | -------------------------------------------------------------------------------- /examples/counter/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-client" 3 | version = "0.1.0" 4 | authors = ["Paul Butler "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [dependencies] 11 | aper = {path = "../../../aper"} 12 | aper-yew = {path = "../../../aper-yew"} 13 | console_error_panic_hook = "0.1.6" 14 | serde = { version = "1.0.124", features = ["derive"] } 15 | wasm-bindgen = "0.2.83" 16 | web-sys = "0.3.48" 17 | yew = { version="0.21.0", features=["csr"] } 18 | counter-common = {path="../common"} 19 | -------------------------------------------------------------------------------- /examples/counter/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::AperSync; 2 | use aper_yew::{FakeSend, YewAperClient}; 3 | pub use counter_common::{Counter, CounterIntent}; 4 | use wasm_bindgen::prelude::*; 5 | use yew::{ 6 | prelude::{function_component, html, Html, Properties}, 7 | use_state, 8 | }; 9 | 10 | #[derive(Clone, PartialEq, Properties)] 11 | struct CounterViewProps { 12 | connection: YewAperClient, 13 | } 14 | 15 | #[function_component] 16 | fn CounterView(props: &CounterViewProps) -> Html { 17 | let counter = props.connection.state(); 18 | 19 | let state = use_state(|| 0); 20 | 21 | let state_ = FakeSend { value: state }; 22 | let c = counter.clone(); 23 | counter.value.listen(move || { 24 | state_.value.set(c.value()); 25 | true 26 | }); 27 | 28 | html! { 29 |
30 |

{&format!("Counter: {}", counter.value())}

31 | 34 | 37 | 40 |
41 | } 42 | } 43 | 44 | #[wasm_bindgen(start)] 45 | pub fn entry() { 46 | let url = "ws://localhost:8080/ws"; 47 | 48 | let connection = YewAperClient::::new(url); 49 | 50 | let props = CounterViewProps { connection }; 51 | 52 | yew::Renderer::::with_props(props).render(); 53 | } 54 | -------------------------------------------------------------------------------- /examples/counter/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-common" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | aper = {path="../../../aper"} 8 | serde = { version = "1.0.127", features = ["derive"] } 9 | -------------------------------------------------------------------------------- /examples/counter/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::{data_structures::atom::Atom, Aper, AperSync, IntentEvent}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(AperSync, Clone)] 5 | pub struct Counter { 6 | pub value: Atom, 7 | } 8 | 9 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 10 | pub enum CounterIntent { 11 | Add(i64), 12 | Subtract(i64), 13 | Reset, 14 | } 15 | 16 | impl Counter { 17 | pub fn value(&self) -> i64 { 18 | self.value.get() 19 | } 20 | } 21 | 22 | impl Aper for Counter { 23 | type Intent = CounterIntent; 24 | type Error = (); 25 | 26 | fn apply(&mut self, event: &IntentEvent) -> Result<(), ()> { 27 | let value = self.value.get(); 28 | 29 | match &event.intent { 30 | CounterIntent::Add(i) => { 31 | self.value.set(value + i); 32 | } 33 | CounterIntent::Subtract(i) => { 34 | self.value.set(value - i); 35 | } 36 | CounterIntent::Reset => { 37 | self.value.set(0); 38 | } 39 | } 40 | 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/counter/service/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-wasi" 3 | -------------------------------------------------------------------------------- /examples/counter/service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter-service" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | aper-stateroom = { path="../../../aper-stateroom" } 11 | aper = { path="../../../aper" } 12 | stateroom-wasm = { version="0.4.0" } 13 | counter-common = { path="../common" } 14 | -------------------------------------------------------------------------------- /examples/counter/service/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper_stateroom::AperStateroomService; 2 | use counter_common::Counter; 3 | use stateroom_wasm::stateroom_wasm; 4 | 5 | #[stateroom_wasm] 6 | type CounterService = AperStateroomService; 7 | -------------------------------------------------------------------------------- /examples/counter/stateroom.toml: -------------------------------------------------------------------------------- 1 | static_files = "static" 2 | 3 | [client] 4 | package = "counter-client" 5 | 6 | [service] 7 | package = "counter-service" 8 | -------------------------------------------------------------------------------- /examples/counter/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Aper Project 6 | 7 | 8 | 17 | 18 | 19 |
Loading... (If this message doesn't disappear, your browser may not be supported or something may have gone wrong.)
20 | 21 | -------------------------------------------------------------------------------- /examples/drop-four/.gitignore: -------------------------------------------------------------------------------- 1 | static-client 2 | client-pkg 3 | -------------------------------------------------------------------------------- /examples/drop-four/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "client", 5 | "service", 6 | "common", 7 | ] -------------------------------------------------------------------------------- /examples/drop-four/client/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" 3 | -------------------------------------------------------------------------------- /examples/drop-four/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "drop-four-client" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | aper = {path="../../../aper"} 11 | aper-stateroom = {path="../../../aper-stateroom"} 12 | aper-yew = {path="../../../aper-yew"} 13 | serde = { version = "1.0.127", features = ["derive"] } 14 | wasm-bindgen = "0.2.83" 15 | yew = { version = "0.21.0", features = ["csr"] } 16 | drop-four-common = {path="../common"} 17 | -------------------------------------------------------------------------------- /examples/drop-four/client/src/board_component.rs: -------------------------------------------------------------------------------- 1 | use crate::{Board, GameTransition, PlayerColor, BOARD_COLS, BOARD_ROWS}; 2 | use aper_yew::YewAperClient; 3 | use drop_four_common::DropFourGame; 4 | use yew::prelude::*; 5 | use yew::Component; 6 | 7 | const CELL_SIZE: u32 = 80; 8 | const CELL_INNER_SIZE: u32 = 70; 9 | const CELL_HOLE_SIZE: u32 = 60; 10 | 11 | const TEAL: &str = "#4CA9AB"; 12 | const BROWN: &str = "#C4A07F"; 13 | 14 | const BOARD_FG: &str = "#D8E3D7"; 15 | const BOARD_BG: &str = "#bbc4bb"; 16 | 17 | const PADDING_SIDE: u32 = 40; 18 | const PADDING_TOP: u32 = 50; 19 | const PADDING_BOTTOM: u32 = 10; 20 | 21 | pub struct BoardComponent { 22 | hover_col: Option, 23 | } 24 | 25 | pub struct SetHoverCol(Option); 26 | 27 | #[derive(Properties, Clone)] 28 | pub struct BoardProps { 29 | pub connection: YewAperClient, 30 | pub board: Board, 31 | pub player: PlayerColor, 32 | pub interactive: bool, 33 | } 34 | 35 | impl PartialEq for BoardProps { 36 | fn eq(&self, _other: &Self) -> bool { 37 | false 38 | } 39 | } 40 | 41 | impl BoardComponent { 42 | fn view_disc(&self, player: PlayerColor, offset: i32) -> Html { 43 | let color = match player { 44 | PlayerColor::Brown => BROWN, 45 | PlayerColor::Teal => TEAL, 46 | }; 47 | 48 | html! { 49 | 50 | 53 | 58 | 59 | } 60 | } 61 | 62 | fn view_holes(&self) -> impl Iterator { 63 | (0..BOARD_COLS).flat_map(|c| { 64 | (0..BOARD_ROWS).map(move |r| { 65 | html! {} 71 | }) 72 | }) 73 | } 74 | 75 | fn view_hover_zones(&self, context: &yew::Context) -> Html { 76 | let set_hover_col = context.link().callback(SetHoverCol); 77 | let zones = (0..BOARD_COLS).map(move |c| { 78 | html! { 79 | 87 | } 88 | }); 89 | 90 | html! { 91 | 92 | { for zones } 93 | 94 | } 95 | } 96 | 97 | fn view_tentative_disc(&self, context: &yew::Context) -> Html { 98 | if let Some(disc_col) = self.hover_col { 99 | if context.props().interactive { 100 | let tx = CELL_SIZE * disc_col + CELL_SIZE / 2; 101 | let ty = CELL_SIZE / 2; 102 | let style = format!("transform: translate({}px, {}px)", tx, ty); 103 | 104 | return html! { 105 | 106 | { self.view_disc(context.props().player, -(CELL_INNER_SIZE as i32) / 2) } 107 | 108 | }; 109 | } 110 | } 111 | 112 | html! {} 113 | } 114 | 115 | fn view_played_discs(&self, context: &yew::Context) -> Html { 116 | let board = &context.props().board; 117 | 118 | let col_groups = (0..BOARD_COLS).map(|col| { 119 | let discs = (0..BOARD_ROWS).rev().flat_map(|row| { 120 | board.get(row, col).map(|p| { 121 | let ty = CELL_SIZE * row + CELL_SIZE / 2; 122 | let style = format!("transform: translate(0, {}px)", ty); 123 | let key = format!("{}-{}", row, col); 124 | 125 | html! { 126 | 127 | { self.view_disc(p, 0) } 128 | 129 | } 130 | }) 131 | }); 132 | 133 | let tx = CELL_SIZE * col + CELL_SIZE / 2; 134 | let transform = format!("translate({} 0)", tx); 135 | 136 | html! { 137 | 138 | { for discs } 139 | 140 | } 141 | }); 142 | 143 | html! { 144 | 145 | { for col_groups } 146 | 147 | } 148 | } 149 | } 150 | 151 | impl Component for BoardComponent { 152 | type Properties = BoardProps; 153 | type Message = SetHoverCol; 154 | 155 | fn view(&self, context: &yew::Context) -> Html { 156 | let height = BOARD_ROWS * CELL_SIZE; 157 | let width = BOARD_COLS * CELL_SIZE; 158 | 159 | let svg_width = width + 2 * PADDING_SIDE; 160 | let svg_height = height + PADDING_TOP + PADDING_BOTTOM; 161 | 162 | html! { 163 | 164 | 165 | 166 | { for self.view_holes() } 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | { self.view_played_discs(context) } 179 | 180 | { self.view_tentative_disc(context) } 181 | 182 | 183 | 184 | { self.view_hover_zones(context) } 185 | 186 | 187 | } 188 | } 189 | 190 | fn update(&mut self, _context: &yew::Context, msg: SetHoverCol) -> bool { 191 | let SetHoverCol(c) = msg; 192 | 193 | if c != self.hover_col { 194 | self.hover_col = c; 195 | true 196 | } else { 197 | false 198 | } 199 | } 200 | 201 | fn create(_context: &yew::Context) -> Self { 202 | BoardComponent { hover_col: None } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /examples/drop-four/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::AperSync; 2 | use aper_yew::{FakeSend, YewAperClient}; 3 | use board_component::BoardComponent; 4 | use drop_four_common::{ 5 | Board, DropFourGame, GameTransition, PlayState, PlayerColor, BOARD_COLS, BOARD_ROWS, 6 | }; 7 | use wasm_bindgen::prelude::*; 8 | use yew::prelude::*; 9 | 10 | mod board_component; 11 | 12 | fn view_waiting( 13 | connection: &YewAperClient, 14 | waiting_player: Option, 15 | client_id: u32, 16 | ) -> Html { 17 | if Some(client_id) == waiting_player { 18 | html! { 19 |

{"Waiting for another player."}

20 | } 21 | } else { 22 | let message = if waiting_player.is_some() { 23 | "One player is waiting to play." 24 | } else { 25 | "Nobody is waiting to play." 26 | }; 27 | 28 | html! { 29 |
30 | 31 |

{message}

32 |
33 | } 34 | } 35 | } 36 | 37 | fn view_playing( 38 | connection: &YewAperClient, 39 | board: &Board, 40 | next_player: PlayerColor, 41 | winner: Option, 42 | own_color: Option, 43 | ) -> Html { 44 | let status_message = if let Some(own_color) = own_color { 45 | if let Some(winner) = winner { 46 | if winner == own_color { 47 | "Congrats, you are the winner!".to_string() 48 | } else { 49 | format!("{} is the winner. Better luck next time!", winner.name()) 50 | } 51 | } else if next_player == own_color { 52 | "It's your turn!".to_string() 53 | } else { 54 | format!("It's {}'s turn", next_player.name()) 55 | } 56 | } else { 57 | format!("You're observing. {} is next.", next_player.name()) 58 | }; 59 | 60 | html! { 61 |
62 |

{status_message}

63 | 68 | { 69 | if winner.is_some() { 70 | html! { 71 | 74 | } 75 | } else { 76 | html! {} 77 | } 78 | } 79 |
80 | } 81 | } 82 | 83 | #[function_component] 84 | fn GameInner(props: &DropFourGameProps) -> Html { 85 | let state = props.connection.state(); 86 | let client_id = props.connection.client_id().unwrap_or_default(); 87 | 88 | let force_redraw = FakeSend::new(use_force_update()); 89 | state.player_map.teal_player.listen(move || { 90 | force_redraw.value.force_update(); 91 | true 92 | }); 93 | 94 | let force_redraw = FakeSend::new(use_force_update()); 95 | state.play_state.listen(move || { 96 | force_redraw.value.force_update(); 97 | true 98 | }); 99 | 100 | match state.play_state.get() { 101 | PlayState::Playing => { 102 | let own_color = state.player_map.color_of_player(client_id); 103 | view_playing( 104 | &props.connection, 105 | &state.board, 106 | state.next_player.get(), 107 | state.winner.get(), 108 | own_color, 109 | ) 110 | } 111 | PlayState::Waiting => view_waiting( 112 | &props.connection, 113 | state.player_map.teal_player.get(), 114 | client_id, 115 | ), 116 | } 117 | } 118 | 119 | #[function_component] 120 | fn GameView(props: &DropFourGameProps) -> Html { 121 | html! { 122 |
123 |

{"Drop Four"}

124 | 125 |
126 | } 127 | } 128 | 129 | #[derive(Clone, Properties, PartialEq)] 130 | struct DropFourGameProps { 131 | connection: YewAperClient, 132 | } 133 | 134 | #[wasm_bindgen(start)] 135 | pub fn entry() { 136 | let url = "ws://localhost:8080/ws"; 137 | 138 | let connection = YewAperClient::::new(url); 139 | let props = DropFourGameProps { connection }; 140 | 141 | yew::Renderer::::with_props(props).render(); 142 | } 143 | -------------------------------------------------------------------------------- /examples/drop-four/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "drop-four-common" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | aper = {path="../../../aper"} 8 | aper-stateroom = {path="../../../aper-stateroom"} 9 | serde = { version = "1.0.127", features = ["derive"] } 10 | 11 | [dev-dependencies] 12 | chrono = "0.4.38" 13 | -------------------------------------------------------------------------------- /examples/drop-four/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod state; 2 | 3 | pub use state::*; 4 | -------------------------------------------------------------------------------- /examples/drop-four/service/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-wasi" 3 | -------------------------------------------------------------------------------- /examples/drop-four/service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "drop-four-service" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | aper-stateroom = {path="../../../aper-stateroom"} 11 | stateroom-wasm = { version="0.4.0" } 12 | drop-four-common = { path="../common" } 13 | -------------------------------------------------------------------------------- /examples/drop-four/service/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper_stateroom::AperStateroomService; 2 | pub use drop_four_common::{ 3 | Board, DropFourGame, GameTransition, PlayState, PlayerColor, BOARD_COLS, BOARD_ROWS, 4 | }; 5 | use stateroom_wasm::stateroom_wasm; 6 | 7 | #[stateroom_wasm] 8 | type DropFourService = AperStateroomService; 9 | -------------------------------------------------------------------------------- /examples/drop-four/stateroom.toml: -------------------------------------------------------------------------------- 1 | static_files = "static" 2 | 3 | [client] 4 | package = "drop-four-client" 5 | 6 | [service] 7 | package = "drop-four-service" 8 | -------------------------------------------------------------------------------- /examples/drop-four/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Drop Four 6 | 7 | 8 | 9 | 10 | 11 | 12 |
Loading... (If this message doesn't disappear, your browser may not be supported or something may have gone wrong.)
13 | 14 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/drop-four/static/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400&family=Source+Code+Pro&display=swap'); 2 | 3 | body { 4 | font-family: Montserrat, sans-serif; 5 | } 6 | 7 | .main { 8 | max-width: 650px; 9 | margin: auto; 10 | padding-top: 10px; 11 | } 12 | 13 | @keyframes slidein { 14 | 0% { 15 | opacity: 0.1; 16 | transform: translate(0, 0); 17 | } 18 | 19 | 100% { 20 | opacity 0.8; 21 | } 22 | } 23 | 24 | .disc { 25 | animation-duration: 0.4s; 26 | animation-name: slidein; 27 | animation-timing-function: cubic-bezier(.7,0,1,.79); 28 | } 29 | 30 | .tentative { 31 | transition: transform 0.2s; 32 | } 33 | 34 | button { 35 | padding: 10px 15px; 36 | border-radius: 10px; 37 | border: none; 38 | background-color: #c1cfe8; 39 | color: #002058; 40 | font-size: 14pt; 41 | font-weight: bold; 42 | border-bottom: 1px solid #5a7fbf; 43 | border-right: 1px solid #5a7fbf; 44 | } -------------------------------------------------------------------------------- /examples/timer/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | client-pkg -------------------------------------------------------------------------------- /examples/timer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "client", 5 | "common", 6 | "service", 7 | ] -------------------------------------------------------------------------------- /examples/timer/client/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" 3 | -------------------------------------------------------------------------------- /examples/timer/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timer-client" 3 | version = "0.1.0" 4 | authors = ["Paul Butler "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [dependencies] 11 | aper = { path="../../../aper" } 12 | aper-yew = { path="../../../aper-yew" } 13 | console_error_panic_hook = "0.1.6" 14 | serde = { version = "1.0.124", features = ["derive"] } 15 | wasm-bindgen = "0.2.83" 16 | web-sys = "0.3.48" 17 | yew = { version = "0.21.0", features = ["csr"] } 18 | timer-common = {path="../common"} 19 | -------------------------------------------------------------------------------- /examples/timer/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::AperSync; 2 | use aper_yew::{FakeSend, YewAperClient}; 3 | use timer_common::{Timer, TimerIntent}; 4 | use wasm_bindgen::prelude::*; 5 | use yew::prelude::*; 6 | 7 | #[derive(Properties, Clone, PartialEq)] 8 | struct TimerViewProps { 9 | client: YewAperClient, 10 | } 11 | 12 | #[function_component] 13 | fn TimerView(props: &TimerViewProps) -> Html { 14 | let state = props.client.state(); 15 | let force_update = FakeSend::new(use_force_update()); 16 | 17 | state.value.listen(move || { 18 | force_update.value.force_update(); 19 | true 20 | }); 21 | 22 | html! { 23 |
24 |

{&format!("Timer: {}", state.value.get())}

25 | 28 |
29 | } 30 | } 31 | 32 | #[wasm_bindgen(start)] 33 | pub fn entry() { 34 | let client = YewAperClient::new("ws"); 35 | let props = TimerViewProps { client }; 36 | yew::Renderer::::with_props(props).render(); 37 | } 38 | -------------------------------------------------------------------------------- /examples/timer/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timer-common" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | aper = { path="../../../aper" } 8 | aper-stateroom = {path="../../../aper-stateroom"} 9 | chrono = "0.4.19" 10 | serde = { version = "1.0.127", features = ["derive"] } 11 | -------------------------------------------------------------------------------- /examples/timer/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper::{data_structures::atom::Atom, Aper, AperSync, IntentEvent}; 2 | use chrono::{DateTime, Duration, Utc}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(AperSync, Clone)] 6 | pub struct Timer { 7 | pub value: Atom, 8 | pub last_increment: Atom>, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 12 | pub enum TimerIntent { 13 | Reset, 14 | Increment, 15 | } 16 | 17 | impl Aper for Timer { 18 | type Intent = TimerIntent; 19 | type Error = (); 20 | 21 | fn apply(&mut self, event: &IntentEvent) -> Result<(), ()> { 22 | match event.intent { 23 | TimerIntent::Reset => self.value.set(0), 24 | TimerIntent::Increment => { 25 | self.value.set(self.value.get() + 1); 26 | self.last_increment.set(event.timestamp); 27 | } 28 | } 29 | 30 | Ok(()) 31 | } 32 | 33 | fn suspended_event(&self) -> Option> { 34 | let next_event = self 35 | .last_increment 36 | .get() 37 | .checked_add_signed(Duration::seconds(1)) 38 | .unwrap(); 39 | 40 | Some(IntentEvent::new(None, next_event, TimerIntent::Increment)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/timer/service/.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | target = wasm32-wasi 3 | -------------------------------------------------------------------------------- /examples/timer/service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timer-service" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | aper = { path="../../../aper" } 11 | aper-stateroom = {path="../../../aper-stateroom"} 12 | stateroom-wasm = { version="0.4.0" } 13 | timer-common = { path="../common" } 14 | -------------------------------------------------------------------------------- /examples/timer/service/src/lib.rs: -------------------------------------------------------------------------------- 1 | use aper_stateroom::AperStateroomService; 2 | use stateroom_wasm::stateroom_wasm; 3 | use timer_common::Timer; 4 | 5 | #[stateroom_wasm] 6 | type DropFourService = AperStateroomService; 7 | -------------------------------------------------------------------------------- /examples/timer/stateroom.toml: -------------------------------------------------------------------------------- 1 | static_files = "static" 2 | 3 | [client] 4 | package = "timer-client" 5 | 6 | [service] 7 | package = "timer-service" 8 | -------------------------------------------------------------------------------- /examples/timer/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Aper Project 6 | 7 | 8 | 17 | 18 | 19 |
Loading... (If this message doesn't disappear, your browser may not be supported or something may have gone wrong.)
20 | 21 | -------------------------------------------------------------------------------- /site/.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Run tests 17 | run: cargo test --verbose 18 | - name: Install Cobalt 19 | run: cargo install cobalt-bin 20 | - name: Build website 21 | working-directory: website 22 | run: cobalt build 23 | - name: Install mdbook 24 | run: cargo install mdbook 25 | - name: Build book 26 | run: mdbook build book 27 | - name: Combine site files 28 | run: | 29 | mv website/_site site 30 | mv book/book site/guide 31 | - name: Deploy 🚀 32 | uses: JamesIves/github-pages-deploy-action@4.0.0 33 | with: 34 | branch: gh-pages 35 | folder: site 36 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target 3 | site 4 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | # Aper Website Resources 2 | 3 | The Aper book, website, and doctests repositories. 4 | -------------------------------------------------------------------------------- /site/book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /site/book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Paul Butler"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Getting Started with Aper" 7 | default-theme = "rust" -------------------------------------------------------------------------------- /site/book/src/01-introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | **Aper** is a Rust data synchronization library. Fundamentally, Aper lets you create a `struct` that can be synchronized across multiple instances of your program, possibly running across a network. 4 | 5 | Use-cases of Aper include: 6 | - managing the state of an application with real-time collaboration features 7 | - creating an timestamped audit trail of an arbitrary data structure 8 | - synchronizing the game state of a multiplayer game. 9 | 10 | The core `Aper` library is not tied to a particular transport, but works nicely with WebSocket. The `aper-websocket-client` and `aper-serve` crates define WebAssembly-based client and server libraries for Aper data structures. 11 | 12 | ## Design Goals 13 | 14 | Aper is designed for **server-authoritative** synchronization, where one instance of an Aper program is considered the “server”, and others are ”clients”. 15 | 16 | This is in contrast to conflict-free replicated data types (CRDTs), which are designed to work in peer-to-peer environments. A design goal of Aper is to allow developers to take full advantage of the server authority, which makes it possible to enforce data invariants. 17 | 18 | The other guiding goals of Aper are: 19 | 20 | - Local (optimistic) updates should be fast. 21 | - Data synchronization concerns should not live in application code. 22 | 23 | Aper is designed for structured/nested data. It is not optimized for long, flat sequences like text. 24 | 25 | ## Overview 26 | 27 | Aper provides a number of traits and structs that are key to understanding Aper. 28 | 29 | The **`Store`** struct is the core data store in Aper. Aper knows how to synchronize a `Store` *one-way* across a network, i.e. from the server to clients. 30 | 31 | The **`AperSync`** trait designates a struct that expects to be stored in a `Store`. An `AperSync` struct is really just a reference into some data in the store, along with associated methods for interpreting it as Rust types. 32 | 33 | Since a `Store` can be synchronized by Aper, and `AperSync` is just a reference to data in a `Store`, `AperSync` types can be synchronized. 34 | 35 | But that synchronization is only one-way: from server to clients. Generally, clients will also want to modify the data, which is where the `Aper` trait comes in. 36 | 37 | The **`Aper`** trait designates a struct as being *bidirectionally* synchronizable. It defines a set of actions (called *intents*) that can be performed on the store to update it. 38 | 39 | **`AperClient`** and **`AperServer`** provide a “sans-I/O” client/server sync protocol, implemented for the client- and server-side respectively. Typically, you will not use them directly from application code, but instead use crates like `aper-websocket-client` that use them in combination with a particular I/O library. 40 | -------------------------------------------------------------------------------- /site/book/src/02-one-way-sync.md: -------------------------------------------------------------------------------- 1 | ## One-Way Synchronization 2 | 3 | `AperSync` means that the struct can be synchronized *unidirectionally*. An `AperSync` struct does not own its own data; instead, its fields are references into a `Store`. `Store` is a hierarchical map data structure provided by Aper that can be synchronized across a network. 4 | 5 | Typically, you will not implement `AperSync` directly, but instead derive it. For example, here's a simple `AperSync` struct that could represent an item in a to-do list: 6 | 7 | ```rust 8 | use aper::{AperSync, data_structures::Atom}; 9 | 10 | #[derive(AperSync, Clone)] 11 | struct ToDoItem { 12 | done: Atom, 13 | name: Atom, 14 | } 15 | ``` 16 | 17 | In order to derive `AperSync`, **every field must implement AperSync**. Typically, this means that fields will either be data structures imported from the `aper::data_structures::*` module, or `structs` that you have derived `AperSync` on. 18 | 19 | `Atom` is the most basic `AperSync` type; it represents an atomic value with the provided type. Any serde-serializable type can be used, but keep in mind that these values are opaque to the synchronization system and any modifications mean replacing them entirely. 20 | 21 | Generally, for compound data structures, you should use more appropriate types. Here's an example of using `AtomMap`: 22 | 23 | ```rust 24 | use aper::{AperSync, data_structures::AtomMap}; 25 | 26 | #[derive(AperSync, Clone)] 27 | struct PhoneBook { 28 | name_to_number: AtomMap, 29 | } 30 | ``` 31 | 32 | The `Atom` in `AtomMap` refers to the fact that the **values** of the map act like `Atom`s: they do not need to implement `AperSync`, but must be (de)serializable. 33 | 34 | Aper also provides a type of map where values are `AperSync`. This allows more fine-grained updates to the data structure. For example, you might want to create a todo list by mapping a unique ID to a `ToDoItem`: 35 | 36 | ```rust 37 | use aper::{AperSync, data_structures::{Atom, Map}}; 38 | use uuid::Uuid; 39 | 40 | #[derive(AperSync, Clone)] 41 | struct ToDoItem { 42 | pub done: Atom, 43 | pub name: Atom, 44 | } 45 | 46 | #[derive(AperSync, Clone)] 47 | struct ToDoList { 48 | pub items: Map, 49 | } 50 | ``` 51 | 52 | ## Using `AperSync` types 53 | 54 | `AperSync` structs are constructed by “attaching” them to a `Store`. Every `AperSync` type implicitly has a default 55 | value, which is what you get when you attach it to an empty `Store`. 56 | 57 | When modifying collections of `AperSync` like `Map`, you don't insert new values directly. Instead, you call a method like 58 | `get_or_create` that creates the value as its default, and then call mutators on the value that is returned, like so: 59 | 60 | ```rust 61 | # use aper::{data_structures::{Atom, Map}}; 62 | # use uuid::Uuid; 63 | use aper::{AperSync, Store}; 64 | 65 | # #[derive(AperSync, Clone)] 66 | # struct ToDoItem { 67 | # pub done: Atom, 68 | # pub name: Atom, 69 | # } 70 | # 71 | # #[derive(AperSync, Clone)] 72 | # struct ToDoList { 73 | # pub items: Map, 74 | # } 75 | 76 | fn main() { 77 | let store = Store::default(); 78 | let mut todos = ToDoList::attach(store.handle()); 79 | 80 | let mut todo1 = todos.items.get_or_create(&Uuid::new_v4()); 81 | todo1.name.set("Do laundry".to_string()); 82 | 83 | let mut todo2 = todos.items.get_or_create(&Uuid::new_v4()); 84 | todo2.name.set("Wash dishes".to_string()); 85 | todo2.done.set(true); 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /site/book/src/03-bidirectional-sync.md: -------------------------------------------------------------------------------- 1 | ## Bidirectional Synchronization 2 | 3 | Synchronization in Aper is *asymmetric*, meaning that the method of synchronizing 4 | the data structure from the server to the client is different from the method of 5 | synchronizing from the client to the server. 6 | 7 | The server sends changes to the client by telling it directly how to modify its 8 | `Store`. These messages are called *mutations*. 9 | 10 | For the client to send changes to the server, you need to supply two things: 11 | 12 | - An *intent* type (usually an `enum`), that can represent actions that can be taken 13 | on the data to modify it. 14 | - An `apply` function that takes the data structure and an intent, and updates the 15 | data structure accordingly. 16 | 17 | Both of these, as well as an error type, are provided through the `Aper` trait. 18 | 19 | ### Example 20 | 21 | In the last section, we implemented a `ToDoList`: 22 | 23 | ```rust 24 | use aper::{AperSync, data_structures::{Atom, Map}}; 25 | 26 | #[derive(AperSync, Clone)] 27 | struct ToDoItem { 28 | pub done: Atom, 29 | pub name: Atom, 30 | } 31 | 32 | #[derive(AperSync, Clone)] 33 | struct ToDoList { 34 | pub items: Map, 35 | } 36 | ``` 37 | 38 | To create an **intent** type, we should consider the actions a user might take. A minimal set of intents (inspired by [TodoMVC](https://todomvc.com/)) is: 39 | 40 | - Create a new task 41 | - Change the name of an existing task 42 | - Mark a task as done / not done 43 | - Remove all completed tasks 44 | 45 | In code, that looks like: 46 | 47 | ```rust 48 | use uuid::Uuid; 49 | use serde::{Serialize, Deserialize}; 50 | 51 | #[derive(Serialize, Deserialize, Clone, std::cmp::PartialEq)] 52 | enum ToDoIntent { 53 | CreateTask { 54 | id: Uuid, 55 | name: String, 56 | }, 57 | RenameTask { 58 | id: Uuid, 59 | name: String, 60 | }, 61 | MarkDone { 62 | id: Uuid, 63 | done: bool, 64 | }, 65 | RemoveCompleted, 66 | } 67 | ``` 68 | 69 | Note that when we take action on an existing task, we need a way to identify it, 70 | so we give each task a universally unique identifier (UUID). This UUID is generated 71 | on the client and sent as part of the `CreateTask` message. 72 | 73 | This is a bit different from what you might expect if you're used to remote procedure 74 | call (RPC) APIs, where the server is responsible for generating IDs. It's important 75 | here because the client may need to create an intent that refers to a task before it 76 | hears back from the server with an ID (for example, if the network is interrupted 77 | or the user has gone offline.) 78 | 79 | Now, we implement `Aper` for `ToDoList`: 80 | 81 | ```rust 82 | # use aper::{AperSync, data_structures::{Atom, Map}, IntentMetadata}; 83 | # use serde::{Serialize, Deserialize}; 84 | # use uuid::Uuid; 85 | # 86 | # #[derive(AperSync, Clone)] 87 | # struct ToDoItem { 88 | # pub done: Atom, 89 | # pub name: Atom, 90 | # } 91 | # 92 | # #[derive(AperSync, Clone)] 93 | # struct ToDoList { 94 | # pub items: Map, 95 | # } 96 | # 97 | # #[derive(Serialize, Deserialize, Clone, std::cmp::PartialEq)] 98 | # enum ToDoIntent { 99 | # CreateTask { 100 | # id: Uuid, 101 | # name: String, 102 | # }, 103 | # RenameTask { 104 | # id: Uuid, 105 | # name: String, 106 | # }, 107 | # MarkDone { 108 | # id: Uuid, 109 | # done: bool, 110 | # }, 111 | # RemoveCompleted, 112 | # } 113 | 114 | use aper::Aper; 115 | 116 | impl Aper for ToDoList { 117 | type Intent = ToDoIntent; 118 | type Error = (); 119 | 120 | fn apply(&mut self, intent: &ToDoIntent, _metadata: &IntentMetadata) -> Result<(), ()> { 121 | match intent { 122 | ToDoIntent::CreateTask { id, name } => { 123 | let mut item = self.items.get_or_create(id); 124 | item.name.set(name.to_string()); 125 | item.done.set(false); 126 | }, 127 | ToDoIntent::RenameTask { id, name } => { 128 | // Unlike CreateTask, we bail early with an `Err` if 129 | // the item doesn't exist. Most likely, the server has 130 | // seen a `RemoveCompleted` that removed the item, but 131 | // a client attempted to rename it before the removal 132 | // was synced to it. 133 | let mut item = self.items.get(id).ok_or(())?; 134 | item.name.set(name.to_string()); 135 | } 136 | ToDoIntent::MarkDone { id, done } => { 137 | let mut item = self.items.get(id).ok_or(())?; 138 | item.done.set(*done); 139 | } 140 | ToDoIntent::RemoveCompleted => { 141 | // TODO: need some way to iterate from Map first! 142 | } 143 | } 144 | 145 | Ok(()) 146 | } 147 | } 148 | ``` 149 | -------------------------------------------------------------------------------- /site/book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./01-introduction.md) 4 | - [One-Way Synchronization](./02-one-way-sync.md) 5 | - [Bidirectional Synchronization](./03-bidirectional-sync.md) 6 | -------------------------------------------------------------------------------- /site/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cargo test --verbose 6 | cargo install cobalt-bin 7 | cd website 8 | cobalt build 9 | cd ../ 10 | cargo install mdbook 11 | mdbook build book 12 | mv website/_site site 13 | mv book/book site/guide 14 | 15 | -------------------------------------------------------------------------------- /site/doc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-doc" 3 | version = "0.1.0" 4 | authors = ["Paul Butler "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | aper = { git = "https://github.com/aper-dev/aper" } 9 | aper-jamsocket = { git = "https://github.com/aper-dev/aper" } 10 | aper-serve = { git = "https://github.com/aper-dev/aper" } 11 | aper-yew = { git = "https://github.com/aper-dev/aper" } 12 | -------------------------------------------------------------------------------- /site/doc/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | unimplemented!() 3 | } -------------------------------------------------------------------------------- /site/doctests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aper-book" 3 | version = "0.2.2" 4 | authors = ["Paul Butler "] 5 | edition = "2018" 6 | publish = false 7 | 8 | [dependencies] 9 | aper = { path = "../../aper" } 10 | doc-comment = "0.3.3" 11 | serde = { version = "1.0.123", features = ["derive"] } 12 | uuid = { version = "1.10.0", features = ["v4", "serde"] } 13 | 14 | -------------------------------------------------------------------------------- /site/doctests/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate doc_comment; 3 | 4 | extern crate aper; 5 | 6 | // Guide book 7 | doctest!("../../book/src/01-introduction.md"); 8 | doctest!("../../book/src/02-one-way-sync.md"); 9 | doctest!("../../book/src/03-bidirectional-sync.md"); 10 | 11 | // Website 12 | doctest!("../../website/index.md"); 13 | -------------------------------------------------------------------------------- /site/website/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/.gitignore: -------------------------------------------------------------------------------- 1 | js 2 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 Hakim El Hattab, http://hakim.se, and reveal.js contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/media/demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/2021-rust-and-tell/media/demo.mov -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/highlight/monokai.css: -------------------------------------------------------------------------------- 1 | /* 2 | Monokai style - ported by Luigi Maselli - http://grigio.org 3 | */ 4 | 5 | .hljs { 6 | display: block; 7 | overflow-x: auto; 8 | padding: 0.5em; 9 | background: #272822; 10 | color: #ddd; 11 | } 12 | 13 | .hljs-tag, 14 | .hljs-keyword, 15 | .hljs-selector-tag, 16 | .hljs-literal, 17 | .hljs-strong, 18 | .hljs-name { 19 | color: #f92672; 20 | } 21 | 22 | .hljs-code { 23 | color: #66d9ef; 24 | } 25 | 26 | .hljs-class .hljs-title { 27 | color: white; 28 | } 29 | 30 | .hljs-attribute, 31 | .hljs-symbol, 32 | .hljs-regexp, 33 | .hljs-link { 34 | color: #bf79db; 35 | } 36 | 37 | .hljs-string, 38 | .hljs-bullet, 39 | .hljs-subst, 40 | .hljs-title, 41 | .hljs-section, 42 | .hljs-emphasis, 43 | .hljs-type, 44 | .hljs-built_in, 45 | .hljs-builtin-name, 46 | .hljs-selector-attr, 47 | .hljs-selector-pseudo, 48 | .hljs-addition, 49 | .hljs-variable, 50 | .hljs-template-tag, 51 | .hljs-template-variable { 52 | color: #a6e22e; 53 | } 54 | 55 | .hljs-comment, 56 | .hljs-quote, 57 | .hljs-deletion, 58 | .hljs-meta { 59 | color: #75715e; 60 | } 61 | 62 | .hljs-keyword, 63 | .hljs-selector-tag, 64 | .hljs-literal, 65 | .hljs-doctag, 66 | .hljs-title, 67 | .hljs-section, 68 | .hljs-type, 69 | .hljs-selector-id { 70 | font-weight: bold; 71 | } 72 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/highlight/zenburn.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Zenburn style from voldmar.ru (c) Vladimir Epifanov 4 | based on dark.css by Ivan Sagalaev 5 | 6 | */ 7 | 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | padding: 0.5em; 12 | background: #3f3f3f; 13 | color: #dcdcdc; 14 | } 15 | 16 | .hljs-keyword, 17 | .hljs-selector-tag, 18 | .hljs-tag { 19 | color: #e3ceab; 20 | } 21 | 22 | .hljs-template-tag { 23 | color: #dcdcdc; 24 | } 25 | 26 | .hljs-number { 27 | color: #8cd0d3; 28 | } 29 | 30 | .hljs-variable, 31 | .hljs-template-variable, 32 | .hljs-attribute { 33 | color: #efdcbc; 34 | } 35 | 36 | .hljs-literal { 37 | color: #efefaf; 38 | } 39 | 40 | .hljs-subst { 41 | color: #8f8f8f; 42 | } 43 | 44 | .hljs-title, 45 | .hljs-name, 46 | .hljs-selector-id, 47 | .hljs-selector-class, 48 | .hljs-section, 49 | .hljs-type { 50 | color: #efef8f; 51 | } 52 | 53 | .hljs-symbol, 54 | .hljs-bullet, 55 | .hljs-link { 56 | color: #dca3a3; 57 | } 58 | 59 | .hljs-deletion, 60 | .hljs-string, 61 | .hljs-built_in, 62 | .hljs-builtin-name { 63 | color: #cc9393; 64 | } 65 | 66 | .hljs-addition, 67 | .hljs-comment, 68 | .hljs-quote, 69 | .hljs-meta { 70 | color: #7f9f7f; 71 | } 72 | 73 | 74 | .hljs-emphasis { 75 | font-style: italic; 76 | } 77 | 78 | .hljs-strong { 79 | font-weight: bold; 80 | } 81 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/math/math.esm.js: -------------------------------------------------------------------------------- 1 | function e(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function t(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function n(n){for(var r=1;r { 8 | 9 | // The reveal.js instance this plugin is attached to 10 | let deck; 11 | 12 | let defaultOptions = { 13 | messageStyle: 'none', 14 | tex2jax: { 15 | inlineMath: [ [ '$', '$' ], [ '\\(', '\\)' ] ], 16 | skipTags: [ 'script', 'noscript', 'style', 'textarea', 'pre' ] 17 | }, 18 | skipStartupTypeset: true 19 | }; 20 | 21 | function loadScript( url, callback ) { 22 | 23 | let head = document.querySelector( 'head' ); 24 | let script = document.createElement( 'script' ); 25 | script.type = 'text/javascript'; 26 | script.src = url; 27 | 28 | // Wrapper for callback to make sure it only fires once 29 | let finish = () => { 30 | if( typeof callback === 'function' ) { 31 | callback.call(); 32 | callback = null; 33 | } 34 | } 35 | 36 | script.onload = finish; 37 | 38 | // IE 39 | script.onreadystatechange = () => { 40 | if ( this.readyState === 'loaded' ) { 41 | finish(); 42 | } 43 | } 44 | 45 | // Normal browsers 46 | head.appendChild( script ); 47 | 48 | } 49 | 50 | return { 51 | id: 'math', 52 | 53 | init: function( reveal ) { 54 | 55 | deck = reveal; 56 | 57 | let revealOptions = deck.getConfig().math || {}; 58 | 59 | let options = { ...defaultOptions, ...revealOptions }; 60 | let mathjax = options.mathjax || 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js'; 61 | let config = options.config || 'TeX-AMS_HTML-full'; 62 | let url = mathjax + '?config=' + config; 63 | 64 | options.tex2jax = { ...defaultOptions.tex2jax, ...revealOptions.tex2jax }; 65 | 66 | options.mathjax = options.config = null; 67 | 68 | loadScript( url, function() { 69 | 70 | MathJax.Hub.Config( options ); 71 | 72 | // Typeset followed by an immediate reveal.js layout since 73 | // the typesetting process could affect slide height 74 | MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, deck.getRevealElement() ] ); 75 | MathJax.Hub.Queue( deck.layout ); 76 | 77 | // Reprocess equations in slides when they turn visible 78 | deck.on( 'slidechanged', function( event ) { 79 | 80 | MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, event.currentSlide ] ); 81 | 82 | } ); 83 | 84 | } ); 85 | 86 | } 87 | } 88 | 89 | }; 90 | 91 | export default Plugin; 92 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/notes/plugin.js: -------------------------------------------------------------------------------- 1 | import speakerViewHTML from './speaker-view.html'; 2 | 3 | import marked from 'marked'; 4 | 5 | /** 6 | * Handles opening of and synchronization with the reveal.js 7 | * notes window. 8 | * 9 | * Handshake process: 10 | * 1. This window posts 'connect' to notes window 11 | * - Includes URL of presentation to show 12 | * 2. Notes window responds with 'connected' when it is available 13 | * 3. This window proceeds to send the current presentation state 14 | * to the notes window 15 | */ 16 | const Plugin = () => { 17 | 18 | let popup = null; 19 | 20 | let deck; 21 | 22 | function openNotes() { 23 | 24 | if (popup && !popup.closed) { 25 | popup.focus(); 26 | return; 27 | } 28 | 29 | popup = window.open( 'about:blank', 'reveal.js - Notes', 'width=1100,height=700' ); 30 | popup.marked = marked; 31 | popup.document.write( speakerViewHTML ); 32 | 33 | if( !popup ) { 34 | alert( 'Speaker view popup failed to open. Please make sure popups are allowed and reopen the speaker view.' ); 35 | return; 36 | } 37 | 38 | /** 39 | * Connect to the notes window through a postmessage handshake. 40 | * Using postmessage enables us to work in situations where the 41 | * origins differ, such as a presentation being opened from the 42 | * file system. 43 | */ 44 | function connect() { 45 | // Keep trying to connect until we get a 'connected' message back 46 | let connectInterval = setInterval( function() { 47 | popup.postMessage( JSON.stringify( { 48 | namespace: 'reveal-notes', 49 | type: 'connect', 50 | url: window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search, 51 | state: deck.getState() 52 | } ), '*' ); 53 | }, 500 ); 54 | 55 | window.addEventListener( 'message', function( event ) { 56 | let data = JSON.parse( event.data ); 57 | if( data && data.namespace === 'reveal-notes' && data.type === 'connected' ) { 58 | clearInterval( connectInterval ); 59 | onConnected(); 60 | } 61 | if( data && data.namespace === 'reveal-notes' && data.type === 'call' ) { 62 | callRevealApi( data.methodName, data.arguments, data.callId ); 63 | } 64 | } ); 65 | } 66 | 67 | /** 68 | * Calls the specified Reveal.js method with the provided argument 69 | * and then pushes the result to the notes frame. 70 | */ 71 | function callRevealApi( methodName, methodArguments, callId ) { 72 | 73 | let result = deck[methodName].apply( deck, methodArguments ); 74 | popup.postMessage( JSON.stringify( { 75 | namespace: 'reveal-notes', 76 | type: 'return', 77 | result: result, 78 | callId: callId 79 | } ), '*' ); 80 | 81 | } 82 | 83 | /** 84 | * Posts the current slide data to the notes window 85 | */ 86 | function post( event ) { 87 | 88 | let slideElement = deck.getCurrentSlide(), 89 | notesElement = slideElement.querySelector( 'aside.notes' ), 90 | fragmentElement = slideElement.querySelector( '.current-fragment' ); 91 | 92 | let messageData = { 93 | namespace: 'reveal-notes', 94 | type: 'state', 95 | notes: '', 96 | markdown: false, 97 | whitespace: 'normal', 98 | state: deck.getState() 99 | }; 100 | 101 | // Look for notes defined in a slide attribute 102 | if( slideElement.hasAttribute( 'data-notes' ) ) { 103 | messageData.notes = slideElement.getAttribute( 'data-notes' ); 104 | messageData.whitespace = 'pre-wrap'; 105 | } 106 | 107 | // Look for notes defined in a fragment 108 | if( fragmentElement ) { 109 | let fragmentNotes = fragmentElement.querySelector( 'aside.notes' ); 110 | if( fragmentNotes ) { 111 | notesElement = fragmentNotes; 112 | } 113 | else if( fragmentElement.hasAttribute( 'data-notes' ) ) { 114 | messageData.notes = fragmentElement.getAttribute( 'data-notes' ); 115 | messageData.whitespace = 'pre-wrap'; 116 | 117 | // In case there are slide notes 118 | notesElement = null; 119 | } 120 | } 121 | 122 | // Look for notes defined in an aside element 123 | if( notesElement ) { 124 | messageData.notes = notesElement.innerHTML; 125 | messageData.markdown = typeof notesElement.getAttribute( 'data-markdown' ) === 'string'; 126 | } 127 | 128 | popup.postMessage( JSON.stringify( messageData ), '*' ); 129 | 130 | } 131 | 132 | /** 133 | * Called once we have established a connection to the notes 134 | * window. 135 | */ 136 | function onConnected() { 137 | 138 | // Monitor events that trigger a change in state 139 | deck.on( 'slidechanged', post ); 140 | deck.on( 'fragmentshown', post ); 141 | deck.on( 'fragmenthidden', post ); 142 | deck.on( 'overviewhidden', post ); 143 | deck.on( 'overviewshown', post ); 144 | deck.on( 'paused', post ); 145 | deck.on( 'resumed', post ); 146 | 147 | // Post the initial state 148 | post(); 149 | 150 | } 151 | 152 | connect(); 153 | 154 | } 155 | 156 | return { 157 | id: 'notes', 158 | 159 | init: function( reveal ) { 160 | 161 | deck = reveal; 162 | 163 | if( !/receiver/i.test( window.location.search ) ) { 164 | 165 | // If the there's a 'notes' query set, open directly 166 | if( window.location.search.match( /(\?|\&)notes/gi ) !== null ) { 167 | openNotes(); 168 | } 169 | 170 | // Open the notes when the 's' key is hit 171 | deck.addKeyBinding({keyCode: 83, key: 'S', description: 'Speaker notes view'}, function() { 172 | openNotes(); 173 | } ); 174 | 175 | } 176 | 177 | }, 178 | 179 | open: openNotes 180 | }; 181 | 182 | }; 183 | 184 | export default Plugin; 185 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/search/plugin.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Handles finding a text string anywhere in the slides and showing the next occurrence to the user 3 | * by navigatating to that slide and highlighting it. 4 | * 5 | * @author Jon Snyder , February 2013 6 | */ 7 | 8 | const Plugin = () => { 9 | 10 | // The reveal.js instance this plugin is attached to 11 | let deck; 12 | 13 | let searchElement; 14 | let searchButton; 15 | let searchInput; 16 | 17 | let matchedSlides; 18 | let currentMatchedIndex; 19 | let searchboxDirty; 20 | let hilitor; 21 | 22 | function render() { 23 | 24 | searchElement = document.createElement( 'div' ); 25 | searchElement.classList.add( 'searchbox' ); 26 | searchElement.style.position = 'absolute'; 27 | searchElement.style.top = '10px'; 28 | searchElement.style.right = '10px'; 29 | searchElement.style.zIndex = 10; 30 | 31 | //embedded base64 search icon Designed by Sketchdock - http://www.sketchdock.com/: 32 | searchElement.innerHTML = ` 33 | `; 34 | 35 | searchInput = searchElement.querySelector( '.searchinput' ); 36 | searchInput.style.width = '240px'; 37 | searchInput.style.fontSize = '14px'; 38 | searchInput.style.padding = '4px 6px'; 39 | searchInput.style.color = '#000'; 40 | searchInput.style.background = '#fff'; 41 | searchInput.style.borderRadius = '2px'; 42 | searchInput.style.border = '0'; 43 | searchInput.style.outline = '0'; 44 | searchInput.style.boxShadow = '0 2px 18px rgba(0, 0, 0, 0.2)'; 45 | searchInput.style['-webkit-appearance'] = 'none'; 46 | 47 | deck.getRevealElement().appendChild( searchElement ); 48 | 49 | // searchButton.addEventListener( 'click', function(event) { 50 | // doSearch(); 51 | // }, false ); 52 | 53 | searchInput.addEventListener( 'keyup', function( event ) { 54 | switch (event.keyCode) { 55 | case 13: 56 | event.preventDefault(); 57 | doSearch(); 58 | searchboxDirty = false; 59 | break; 60 | default: 61 | searchboxDirty = true; 62 | } 63 | }, false ); 64 | 65 | closeSearch(); 66 | 67 | } 68 | 69 | function openSearch() { 70 | if( !searchElement ) render(); 71 | 72 | searchElement.style.display = 'inline'; 73 | searchInput.focus(); 74 | searchInput.select(); 75 | } 76 | 77 | function closeSearch() { 78 | if( !searchElement ) render(); 79 | 80 | searchElement.style.display = 'none'; 81 | if(hilitor) hilitor.remove(); 82 | } 83 | 84 | function toggleSearch() { 85 | if( !searchElement ) render(); 86 | 87 | if (searchElement.style.display !== 'inline') { 88 | openSearch(); 89 | } 90 | else { 91 | closeSearch(); 92 | } 93 | } 94 | 95 | function doSearch() { 96 | //if there's been a change in the search term, perform a new search: 97 | if (searchboxDirty) { 98 | var searchstring = searchInput.value; 99 | 100 | if (searchstring === '') { 101 | if(hilitor) hilitor.remove(); 102 | matchedSlides = null; 103 | } 104 | else { 105 | //find the keyword amongst the slides 106 | hilitor = new Hilitor("slidecontent"); 107 | matchedSlides = hilitor.apply(searchstring); 108 | currentMatchedIndex = 0; 109 | } 110 | } 111 | 112 | if (matchedSlides) { 113 | //navigate to the next slide that has the keyword, wrapping to the first if necessary 114 | if (matchedSlides.length && (matchedSlides.length <= currentMatchedIndex)) { 115 | currentMatchedIndex = 0; 116 | } 117 | if (matchedSlides.length > currentMatchedIndex) { 118 | deck.slide(matchedSlides[currentMatchedIndex].h, matchedSlides[currentMatchedIndex].v); 119 | currentMatchedIndex++; 120 | } 121 | } 122 | } 123 | 124 | // Original JavaScript code by Chirp Internet: www.chirp.com.au 125 | // Please acknowledge use of this code by including this header. 126 | // 2/2013 jon: modified regex to display any match, not restricted to word boundaries. 127 | function Hilitor(id, tag) { 128 | 129 | var targetNode = document.getElementById(id) || document.body; 130 | var hiliteTag = tag || "EM"; 131 | var skipTags = new RegExp("^(?:" + hiliteTag + "|SCRIPT|FORM)$"); 132 | var colors = ["#ff6", "#a0ffff", "#9f9", "#f99", "#f6f"]; 133 | var wordColor = []; 134 | var colorIdx = 0; 135 | var matchRegex = ""; 136 | var matchingSlides = []; 137 | 138 | this.setRegex = function(input) 139 | { 140 | input = input.replace(/^[^\w]+|[^\w]+$/g, "").replace(/[^\w'-]+/g, "|"); 141 | matchRegex = new RegExp("(" + input + ")","i"); 142 | } 143 | 144 | this.getRegex = function() 145 | { 146 | return matchRegex.toString().replace(/^\/\\b\(|\)\\b\/i$/g, "").replace(/\|/g, " "); 147 | } 148 | 149 | // recursively apply word highlighting 150 | this.hiliteWords = function(node) 151 | { 152 | if(node == undefined || !node) return; 153 | if(!matchRegex) return; 154 | if(skipTags.test(node.nodeName)) return; 155 | 156 | if(node.hasChildNodes()) { 157 | for(var i=0; i < node.childNodes.length; i++) 158 | this.hiliteWords(node.childNodes[i]); 159 | } 160 | if(node.nodeType == 3) { // NODE_TEXT 161 | var nv, regs; 162 | if((nv = node.nodeValue) && (regs = matchRegex.exec(nv))) { 163 | //find the slide's section element and save it in our list of matching slides 164 | var secnode = node; 165 | while (secnode != null && secnode.nodeName != 'SECTION') { 166 | secnode = secnode.parentNode; 167 | } 168 | 169 | var slideIndex = deck.getIndices(secnode); 170 | var slidelen = matchingSlides.length; 171 | var alreadyAdded = false; 172 | for (var i=0; i < slidelen; i++) { 173 | if ( (matchingSlides[i].h === slideIndex.h) && (matchingSlides[i].v === slideIndex.v) ) { 174 | alreadyAdded = true; 175 | } 176 | } 177 | if (! alreadyAdded) { 178 | matchingSlides.push(slideIndex); 179 | } 180 | 181 | if(!wordColor[regs[0].toLowerCase()]) { 182 | wordColor[regs[0].toLowerCase()] = colors[colorIdx++ % colors.length]; 183 | } 184 | 185 | var match = document.createElement(hiliteTag); 186 | match.appendChild(document.createTextNode(regs[0])); 187 | match.style.backgroundColor = wordColor[regs[0].toLowerCase()]; 188 | match.style.fontStyle = "inherit"; 189 | match.style.color = "#000"; 190 | 191 | var after = node.splitText(regs.index); 192 | after.nodeValue = after.nodeValue.substring(regs[0].length); 193 | node.parentNode.insertBefore(match, after); 194 | } 195 | } 196 | }; 197 | 198 | // remove highlighting 199 | this.remove = function() 200 | { 201 | var arr = document.getElementsByTagName(hiliteTag); 202 | var el; 203 | while(arr.length && (el = arr[0])) { 204 | el.parentNode.replaceChild(el.firstChild, el); 205 | } 206 | }; 207 | 208 | // start highlighting at target node 209 | this.apply = function(input) 210 | { 211 | if(input == undefined || !input) return; 212 | this.remove(); 213 | this.setRegex(input); 214 | this.hiliteWords(targetNode); 215 | return matchingSlides; 216 | }; 217 | 218 | } 219 | 220 | return { 221 | 222 | id: 'search', 223 | 224 | init: reveal => { 225 | 226 | deck = reveal; 227 | deck.registerKeyboardShortcut( 'CTRL + Shift + F', 'Search' ); 228 | 229 | document.addEventListener( 'keydown', function( event ) { 230 | if( event.key == "F" && (event.ctrlKey || event.metaKey) ) { //Control+Shift+f 231 | event.preventDefault(); 232 | toggleSearch(); 233 | } 234 | }, false ); 235 | 236 | }, 237 | 238 | open: openSearch 239 | 240 | } 241 | }; 242 | 243 | export default Plugin; -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/zoom/zoom.esm.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * reveal.js Zoom plugin 3 | */ 4 | var e={id:"zoom",init:function(e){e.getRevealElement().addEventListener("mousedown",(function(o){var n=/Linux/.test(window.navigator.platform)?"ctrl":"alt",i=(e.getConfig().zoomKey?e.getConfig().zoomKey:n)+"Key",d=e.getConfig().zoomLevel?e.getConfig().zoomLevel:2;o[i]&&!e.isOverview()&&(o.preventDefault(),t.to({x:o.clientX,y:o.clientY,scale:d,pan:!1}))}))}},t=function(){var e=1,o=0,n=0,i=-1,d=-1,s="WebkitTransform"in document.body.style||"MozTransform"in document.body.style||"msTransform"in document.body.style||"OTransform"in document.body.style||"transform"in document.body.style;function r(t,o){var n=y();if(t.width=t.width||1,t.height=t.height||1,t.x-=(window.innerWidth-t.width*o)/2,t.y-=(window.innerHeight-t.height*o)/2,s)if(1===o)document.body.style.transform="",document.body.style.OTransform="",document.body.style.msTransform="",document.body.style.MozTransform="",document.body.style.WebkitTransform="";else{var i=n.x+"px "+n.y+"px",d="translate("+-t.x+"px,"+-t.y+"px) scale("+o+")";document.body.style.transformOrigin=i,document.body.style.OTransformOrigin=i,document.body.style.msTransformOrigin=i,document.body.style.MozTransformOrigin=i,document.body.style.WebkitTransformOrigin=i,document.body.style.transform=d,document.body.style.OTransform=d,document.body.style.msTransform=d,document.body.style.MozTransform=d,document.body.style.WebkitTransform=d}else 1===o?(document.body.style.position="",document.body.style.left="",document.body.style.top="",document.body.style.width="",document.body.style.height="",document.body.style.zoom=""):(document.body.style.position="relative",document.body.style.left=-(n.x+t.x)/o+"px",document.body.style.top=-(n.y+t.y)/o+"px",document.body.style.width=100*o+"%",document.body.style.height=100*o+"%",document.body.style.zoom=o);e=o,document.documentElement.classList&&(1!==e?document.documentElement.classList.add("zoomed"):document.documentElement.classList.remove("zoomed"))}function m(){var t=.12*window.innerWidth,i=.12*window.innerHeight,d=y();nwindow.innerHeight-i&&window.scroll(d.x,d.y+(1-(window.innerHeight-n)/i)*(14/e)),owindow.innerWidth-t&&window.scroll(d.x+(1-(window.innerWidth-o)/t)*(14/e),d.y)}function y(){return{x:void 0!==window.scrollX?window.scrollX:window.pageXOffset,y:void 0!==window.scrollY?window.scrollY:window.pageYOffset}}return s&&(document.body.style.transition="transform 0.8s ease",document.body.style.OTransition="-o-transform 0.8s ease",document.body.style.msTransition="-ms-transform 0.8s ease",document.body.style.MozTransition="-moz-transform 0.8s ease",document.body.style.WebkitTransition="-webkit-transform 0.8s ease"),document.addEventListener("keyup",(function(o){1!==e&&27===o.keyCode&&t.out()})),document.addEventListener("mousemove",(function(t){1!==e&&(o=t.clientX,n=t.clientY)})),{to:function(o){if(1!==e)t.out();else{if(o.x=o.x||0,o.y=o.y||0,o.element){var n=o.element.getBoundingClientRect();o.x=n.left-20,o.y=n.top-20,o.width=n.width+40,o.height=n.height+40}void 0!==o.width&&void 0!==o.height&&(o.scale=Math.max(Math.min(window.innerWidth/o.width,window.innerHeight/o.height),1)),o.scale>1&&(o.x*=o.scale,o.y*=o.scale,r(o,o.scale),!1!==o.pan&&(i=setTimeout((function(){d=setInterval(m,1e3/60)}),800)))}},out:function(){clearTimeout(i),clearInterval(d),r({x:0,y:0},1),e=1},magnify:function(e){this.to(e)},reset:function(){this.out()},zoomLevel:function(){return e}}}();export default function(){return e} 5 | -------------------------------------------------------------------------------- /site/website/2021-rust-and-tell/plugin/zoom/zoom.js: -------------------------------------------------------------------------------- 1 | !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).RevealZoom=o()}(this,(function(){"use strict"; 2 | /*! 3 | * reveal.js Zoom plugin 4 | */var e={id:"zoom",init:function(e){e.getRevealElement().addEventListener("mousedown",(function(t){var n=/Linux/.test(window.navigator.platform)?"ctrl":"alt",i=(e.getConfig().zoomKey?e.getConfig().zoomKey:n)+"Key",d=e.getConfig().zoomLevel?e.getConfig().zoomLevel:2;t[i]&&!e.isOverview()&&(t.preventDefault(),o.to({x:t.clientX,y:t.clientY,scale:d,pan:!1}))}))}},o=function(){var e=1,t=0,n=0,i=-1,d=-1,s="WebkitTransform"in document.body.style||"MozTransform"in document.body.style||"msTransform"in document.body.style||"OTransform"in document.body.style||"transform"in document.body.style;function r(o,t){var n=l();if(o.width=o.width||1,o.height=o.height||1,o.x-=(window.innerWidth-o.width*t)/2,o.y-=(window.innerHeight-o.height*t)/2,s)if(1===t)document.body.style.transform="",document.body.style.OTransform="",document.body.style.msTransform="",document.body.style.MozTransform="",document.body.style.WebkitTransform="";else{var i=n.x+"px "+n.y+"px",d="translate("+-o.x+"px,"+-o.y+"px) scale("+t+")";document.body.style.transformOrigin=i,document.body.style.OTransformOrigin=i,document.body.style.msTransformOrigin=i,document.body.style.MozTransformOrigin=i,document.body.style.WebkitTransformOrigin=i,document.body.style.transform=d,document.body.style.OTransform=d,document.body.style.msTransform=d,document.body.style.MozTransform=d,document.body.style.WebkitTransform=d}else 1===t?(document.body.style.position="",document.body.style.left="",document.body.style.top="",document.body.style.width="",document.body.style.height="",document.body.style.zoom=""):(document.body.style.position="relative",document.body.style.left=-(n.x+o.x)/t+"px",document.body.style.top=-(n.y+o.y)/t+"px",document.body.style.width=100*t+"%",document.body.style.height=100*t+"%",document.body.style.zoom=t);e=t,document.documentElement.classList&&(1!==e?document.documentElement.classList.add("zoomed"):document.documentElement.classList.remove("zoomed"))}function m(){var o=.12*window.innerWidth,i=.12*window.innerHeight,d=l();nwindow.innerHeight-i&&window.scroll(d.x,d.y+(1-(window.innerHeight-n)/i)*(14/e)),twindow.innerWidth-o&&window.scroll(d.x+(1-(window.innerWidth-t)/o)*(14/e),d.y)}function l(){return{x:void 0!==window.scrollX?window.scrollX:window.pageXOffset,y:void 0!==window.scrollY?window.scrollY:window.pageYOffset}}return s&&(document.body.style.transition="transform 0.8s ease",document.body.style.OTransition="-o-transform 0.8s ease",document.body.style.msTransition="-ms-transform 0.8s ease",document.body.style.MozTransition="-moz-transform 0.8s ease",document.body.style.WebkitTransition="-webkit-transform 0.8s ease"),document.addEventListener("keyup",(function(t){1!==e&&27===t.keyCode&&o.out()})),document.addEventListener("mousemove",(function(o){1!==e&&(t=o.clientX,n=o.clientY)})),{to:function(t){if(1!==e)o.out();else{if(t.x=t.x||0,t.y=t.y||0,t.element){var n=t.element.getBoundingClientRect();t.x=n.left-20,t.y=n.top-20,t.width=n.width+40,t.height=n.height+40}void 0!==t.width&&void 0!==t.height&&(t.scale=Math.max(Math.min(window.innerWidth/t.width,window.innerHeight/t.height),1)),t.scale>1&&(t.x*=t.scale,t.y*=t.scale,r(t,t.scale),!1!==t.pan&&(i=setTimeout((function(){d=setInterval(m,1e3/60)}),800)))}},out:function(){clearTimeout(i),clearInterval(d),r({x:0,y:0},1),e=1},magnify:function(e){this.to(e)},reset:function(){this.out()},zoomLevel:function(){return e}}}();return function(){return e}})); 5 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/.gitignore: -------------------------------------------------------------------------------- 1 | js 2 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 Hakim El Hattab, http://hakim.se, and reveal.js contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/media/demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/2022-rust-nyc/media/demo.mov -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/media/nondeterminism.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/2022-rust-nyc/media/nondeterminism.png -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/media/realtime-blogpost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/2022-rust-nyc/media/realtime-blogpost.png -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/media/wasmbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/2022-rust-nyc/media/wasmbox.png -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/highlight/monokai.css: -------------------------------------------------------------------------------- 1 | /* 2 | Monokai style - ported by Luigi Maselli - http://grigio.org 3 | */ 4 | 5 | .hljs { 6 | display: block; 7 | overflow-x: auto; 8 | padding: 0.5em; 9 | background: #272822; 10 | color: #ddd; 11 | } 12 | 13 | .hljs-tag, 14 | .hljs-keyword, 15 | .hljs-selector-tag, 16 | .hljs-literal, 17 | .hljs-strong, 18 | .hljs-name { 19 | color: #f92672; 20 | } 21 | 22 | .hljs-code { 23 | color: #66d9ef; 24 | } 25 | 26 | .hljs-class .hljs-title { 27 | color: white; 28 | } 29 | 30 | .hljs-attribute, 31 | .hljs-symbol, 32 | .hljs-regexp, 33 | .hljs-link { 34 | color: #bf79db; 35 | } 36 | 37 | .hljs-string, 38 | .hljs-bullet, 39 | .hljs-subst, 40 | .hljs-title, 41 | .hljs-section, 42 | .hljs-emphasis, 43 | .hljs-type, 44 | .hljs-built_in, 45 | .hljs-builtin-name, 46 | .hljs-selector-attr, 47 | .hljs-selector-pseudo, 48 | .hljs-addition, 49 | .hljs-variable, 50 | .hljs-template-tag, 51 | .hljs-template-variable { 52 | color: #a6e22e; 53 | } 54 | 55 | .hljs-comment, 56 | .hljs-quote, 57 | .hljs-deletion, 58 | .hljs-meta { 59 | color: #75715e; 60 | } 61 | 62 | .hljs-keyword, 63 | .hljs-selector-tag, 64 | .hljs-literal, 65 | .hljs-doctag, 66 | .hljs-title, 67 | .hljs-section, 68 | .hljs-type, 69 | .hljs-selector-id { 70 | font-weight: bold; 71 | } 72 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/highlight/zenburn.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Zenburn style from voldmar.ru (c) Vladimir Epifanov 4 | based on dark.css by Ivan Sagalaev 5 | 6 | */ 7 | 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | padding: 0.5em; 12 | background: #3f3f3f; 13 | color: #dcdcdc; 14 | } 15 | 16 | .hljs-keyword, 17 | .hljs-selector-tag, 18 | .hljs-tag { 19 | color: #e3ceab; 20 | } 21 | 22 | .hljs-template-tag { 23 | color: #dcdcdc; 24 | } 25 | 26 | .hljs-number { 27 | color: #8cd0d3; 28 | } 29 | 30 | .hljs-variable, 31 | .hljs-template-variable, 32 | .hljs-attribute { 33 | color: #efdcbc; 34 | } 35 | 36 | .hljs-literal { 37 | color: #efefaf; 38 | } 39 | 40 | .hljs-subst { 41 | color: #8f8f8f; 42 | } 43 | 44 | .hljs-title, 45 | .hljs-name, 46 | .hljs-selector-id, 47 | .hljs-selector-class, 48 | .hljs-section, 49 | .hljs-type { 50 | color: #efef8f; 51 | } 52 | 53 | .hljs-symbol, 54 | .hljs-bullet, 55 | .hljs-link { 56 | color: #dca3a3; 57 | } 58 | 59 | .hljs-deletion, 60 | .hljs-string, 61 | .hljs-built_in, 62 | .hljs-builtin-name { 63 | color: #cc9393; 64 | } 65 | 66 | .hljs-addition, 67 | .hljs-comment, 68 | .hljs-quote, 69 | .hljs-meta { 70 | color: #7f9f7f; 71 | } 72 | 73 | 74 | .hljs-emphasis { 75 | font-style: italic; 76 | } 77 | 78 | .hljs-strong { 79 | font-weight: bold; 80 | } 81 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/math/math.esm.js: -------------------------------------------------------------------------------- 1 | function e(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function t(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function n(n){for(var r=1;r { 8 | 9 | // The reveal.js instance this plugin is attached to 10 | let deck; 11 | 12 | let defaultOptions = { 13 | messageStyle: 'none', 14 | tex2jax: { 15 | inlineMath: [ [ '$', '$' ], [ '\\(', '\\)' ] ], 16 | skipTags: [ 'script', 'noscript', 'style', 'textarea', 'pre' ] 17 | }, 18 | skipStartupTypeset: true 19 | }; 20 | 21 | function loadScript( url, callback ) { 22 | 23 | let head = document.querySelector( 'head' ); 24 | let script = document.createElement( 'script' ); 25 | script.type = 'text/javascript'; 26 | script.src = url; 27 | 28 | // Wrapper for callback to make sure it only fires once 29 | let finish = () => { 30 | if( typeof callback === 'function' ) { 31 | callback.call(); 32 | callback = null; 33 | } 34 | } 35 | 36 | script.onload = finish; 37 | 38 | // IE 39 | script.onreadystatechange = () => { 40 | if ( this.readyState === 'loaded' ) { 41 | finish(); 42 | } 43 | } 44 | 45 | // Normal browsers 46 | head.appendChild( script ); 47 | 48 | } 49 | 50 | return { 51 | id: 'math', 52 | 53 | init: function( reveal ) { 54 | 55 | deck = reveal; 56 | 57 | let revealOptions = deck.getConfig().math || {}; 58 | 59 | let options = { ...defaultOptions, ...revealOptions }; 60 | let mathjax = options.mathjax || 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js'; 61 | let config = options.config || 'TeX-AMS_HTML-full'; 62 | let url = mathjax + '?config=' + config; 63 | 64 | options.tex2jax = { ...defaultOptions.tex2jax, ...revealOptions.tex2jax }; 65 | 66 | options.mathjax = options.config = null; 67 | 68 | loadScript( url, function() { 69 | 70 | MathJax.Hub.Config( options ); 71 | 72 | // Typeset followed by an immediate reveal.js layout since 73 | // the typesetting process could affect slide height 74 | MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, deck.getRevealElement() ] ); 75 | MathJax.Hub.Queue( deck.layout ); 76 | 77 | // Reprocess equations in slides when they turn visible 78 | deck.on( 'slidechanged', function( event ) { 79 | 80 | MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, event.currentSlide ] ); 81 | 82 | } ); 83 | 84 | } ); 85 | 86 | } 87 | } 88 | 89 | }; 90 | 91 | export default Plugin; 92 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/notes/plugin.js: -------------------------------------------------------------------------------- 1 | import speakerViewHTML from './speaker-view.html'; 2 | 3 | import marked from 'marked'; 4 | 5 | /** 6 | * Handles opening of and synchronization with the reveal.js 7 | * notes window. 8 | * 9 | * Handshake process: 10 | * 1. This window posts 'connect' to notes window 11 | * - Includes URL of presentation to show 12 | * 2. Notes window responds with 'connected' when it is available 13 | * 3. This window proceeds to send the current presentation state 14 | * to the notes window 15 | */ 16 | const Plugin = () => { 17 | 18 | let popup = null; 19 | 20 | let deck; 21 | 22 | function openNotes() { 23 | 24 | if (popup && !popup.closed) { 25 | popup.focus(); 26 | return; 27 | } 28 | 29 | popup = window.open( 'about:blank', 'reveal.js - Notes', 'width=1100,height=700' ); 30 | popup.marked = marked; 31 | popup.document.write( speakerViewHTML ); 32 | 33 | if( !popup ) { 34 | alert( 'Speaker view popup failed to open. Please make sure popups are allowed and reopen the speaker view.' ); 35 | return; 36 | } 37 | 38 | /** 39 | * Connect to the notes window through a postmessage handshake. 40 | * Using postmessage enables us to work in situations where the 41 | * origins differ, such as a presentation being opened from the 42 | * file system. 43 | */ 44 | function connect() { 45 | // Keep trying to connect until we get a 'connected' message back 46 | let connectInterval = setInterval( function() { 47 | popup.postMessage( JSON.stringify( { 48 | namespace: 'reveal-notes', 49 | type: 'connect', 50 | url: window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search, 51 | state: deck.getState() 52 | } ), '*' ); 53 | }, 500 ); 54 | 55 | window.addEventListener( 'message', function( event ) { 56 | let data = JSON.parse( event.data ); 57 | if( data && data.namespace === 'reveal-notes' && data.type === 'connected' ) { 58 | clearInterval( connectInterval ); 59 | onConnected(); 60 | } 61 | if( data && data.namespace === 'reveal-notes' && data.type === 'call' ) { 62 | callRevealApi( data.methodName, data.arguments, data.callId ); 63 | } 64 | } ); 65 | } 66 | 67 | /** 68 | * Calls the specified Reveal.js method with the provided argument 69 | * and then pushes the result to the notes frame. 70 | */ 71 | function callRevealApi( methodName, methodArguments, callId ) { 72 | 73 | let result = deck[methodName].apply( deck, methodArguments ); 74 | popup.postMessage( JSON.stringify( { 75 | namespace: 'reveal-notes', 76 | type: 'return', 77 | result: result, 78 | callId: callId 79 | } ), '*' ); 80 | 81 | } 82 | 83 | /** 84 | * Posts the current slide data to the notes window 85 | */ 86 | function post( event ) { 87 | 88 | let slideElement = deck.getCurrentSlide(), 89 | notesElement = slideElement.querySelector( 'aside.notes' ), 90 | fragmentElement = slideElement.querySelector( '.current-fragment' ); 91 | 92 | let messageData = { 93 | namespace: 'reveal-notes', 94 | type: 'state', 95 | notes: '', 96 | markdown: false, 97 | whitespace: 'normal', 98 | state: deck.getState() 99 | }; 100 | 101 | // Look for notes defined in a slide attribute 102 | if( slideElement.hasAttribute( 'data-notes' ) ) { 103 | messageData.notes = slideElement.getAttribute( 'data-notes' ); 104 | messageData.whitespace = 'pre-wrap'; 105 | } 106 | 107 | // Look for notes defined in a fragment 108 | if( fragmentElement ) { 109 | let fragmentNotes = fragmentElement.querySelector( 'aside.notes' ); 110 | if( fragmentNotes ) { 111 | notesElement = fragmentNotes; 112 | } 113 | else if( fragmentElement.hasAttribute( 'data-notes' ) ) { 114 | messageData.notes = fragmentElement.getAttribute( 'data-notes' ); 115 | messageData.whitespace = 'pre-wrap'; 116 | 117 | // In case there are slide notes 118 | notesElement = null; 119 | } 120 | } 121 | 122 | // Look for notes defined in an aside element 123 | if( notesElement ) { 124 | messageData.notes = notesElement.innerHTML; 125 | messageData.markdown = typeof notesElement.getAttribute( 'data-markdown' ) === 'string'; 126 | } 127 | 128 | popup.postMessage( JSON.stringify( messageData ), '*' ); 129 | 130 | } 131 | 132 | /** 133 | * Called once we have established a connection to the notes 134 | * window. 135 | */ 136 | function onConnected() { 137 | 138 | // Monitor events that trigger a change in state 139 | deck.on( 'slidechanged', post ); 140 | deck.on( 'fragmentshown', post ); 141 | deck.on( 'fragmenthidden', post ); 142 | deck.on( 'overviewhidden', post ); 143 | deck.on( 'overviewshown', post ); 144 | deck.on( 'paused', post ); 145 | deck.on( 'resumed', post ); 146 | 147 | // Post the initial state 148 | post(); 149 | 150 | } 151 | 152 | connect(); 153 | 154 | } 155 | 156 | return { 157 | id: 'notes', 158 | 159 | init: function( reveal ) { 160 | 161 | deck = reveal; 162 | 163 | if( !/receiver/i.test( window.location.search ) ) { 164 | 165 | // If the there's a 'notes' query set, open directly 166 | if( window.location.search.match( /(\?|\&)notes/gi ) !== null ) { 167 | openNotes(); 168 | } 169 | 170 | // Open the notes when the 's' key is hit 171 | deck.addKeyBinding({keyCode: 83, key: 'S', description: 'Speaker notes view'}, function() { 172 | openNotes(); 173 | } ); 174 | 175 | } 176 | 177 | }, 178 | 179 | open: openNotes 180 | }; 181 | 182 | }; 183 | 184 | export default Plugin; 185 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/search/plugin.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Handles finding a text string anywhere in the slides and showing the next occurrence to the user 3 | * by navigatating to that slide and highlighting it. 4 | * 5 | * @author Jon Snyder , February 2013 6 | */ 7 | 8 | const Plugin = () => { 9 | 10 | // The reveal.js instance this plugin is attached to 11 | let deck; 12 | 13 | let searchElement; 14 | let searchButton; 15 | let searchInput; 16 | 17 | let matchedSlides; 18 | let currentMatchedIndex; 19 | let searchboxDirty; 20 | let hilitor; 21 | 22 | function render() { 23 | 24 | searchElement = document.createElement( 'div' ); 25 | searchElement.classList.add( 'searchbox' ); 26 | searchElement.style.position = 'absolute'; 27 | searchElement.style.top = '10px'; 28 | searchElement.style.right = '10px'; 29 | searchElement.style.zIndex = 10; 30 | 31 | //embedded base64 search icon Designed by Sketchdock - http://www.sketchdock.com/: 32 | searchElement.innerHTML = ` 33 | `; 34 | 35 | searchInput = searchElement.querySelector( '.searchinput' ); 36 | searchInput.style.width = '240px'; 37 | searchInput.style.fontSize = '14px'; 38 | searchInput.style.padding = '4px 6px'; 39 | searchInput.style.color = '#000'; 40 | searchInput.style.background = '#fff'; 41 | searchInput.style.borderRadius = '2px'; 42 | searchInput.style.border = '0'; 43 | searchInput.style.outline = '0'; 44 | searchInput.style.boxShadow = '0 2px 18px rgba(0, 0, 0, 0.2)'; 45 | searchInput.style['-webkit-appearance'] = 'none'; 46 | 47 | deck.getRevealElement().appendChild( searchElement ); 48 | 49 | // searchButton.addEventListener( 'click', function(event) { 50 | // doSearch(); 51 | // }, false ); 52 | 53 | searchInput.addEventListener( 'keyup', function( event ) { 54 | switch (event.keyCode) { 55 | case 13: 56 | event.preventDefault(); 57 | doSearch(); 58 | searchboxDirty = false; 59 | break; 60 | default: 61 | searchboxDirty = true; 62 | } 63 | }, false ); 64 | 65 | closeSearch(); 66 | 67 | } 68 | 69 | function openSearch() { 70 | if( !searchElement ) render(); 71 | 72 | searchElement.style.display = 'inline'; 73 | searchInput.focus(); 74 | searchInput.select(); 75 | } 76 | 77 | function closeSearch() { 78 | if( !searchElement ) render(); 79 | 80 | searchElement.style.display = 'none'; 81 | if(hilitor) hilitor.remove(); 82 | } 83 | 84 | function toggleSearch() { 85 | if( !searchElement ) render(); 86 | 87 | if (searchElement.style.display !== 'inline') { 88 | openSearch(); 89 | } 90 | else { 91 | closeSearch(); 92 | } 93 | } 94 | 95 | function doSearch() { 96 | //if there's been a change in the search term, perform a new search: 97 | if (searchboxDirty) { 98 | var searchstring = searchInput.value; 99 | 100 | if (searchstring === '') { 101 | if(hilitor) hilitor.remove(); 102 | matchedSlides = null; 103 | } 104 | else { 105 | //find the keyword amongst the slides 106 | hilitor = new Hilitor("slidecontent"); 107 | matchedSlides = hilitor.apply(searchstring); 108 | currentMatchedIndex = 0; 109 | } 110 | } 111 | 112 | if (matchedSlides) { 113 | //navigate to the next slide that has the keyword, wrapping to the first if necessary 114 | if (matchedSlides.length && (matchedSlides.length <= currentMatchedIndex)) { 115 | currentMatchedIndex = 0; 116 | } 117 | if (matchedSlides.length > currentMatchedIndex) { 118 | deck.slide(matchedSlides[currentMatchedIndex].h, matchedSlides[currentMatchedIndex].v); 119 | currentMatchedIndex++; 120 | } 121 | } 122 | } 123 | 124 | // Original JavaScript code by Chirp Internet: www.chirp.com.au 125 | // Please acknowledge use of this code by including this header. 126 | // 2/2013 jon: modified regex to display any match, not restricted to word boundaries. 127 | function Hilitor(id, tag) { 128 | 129 | var targetNode = document.getElementById(id) || document.body; 130 | var hiliteTag = tag || "EM"; 131 | var skipTags = new RegExp("^(?:" + hiliteTag + "|SCRIPT|FORM)$"); 132 | var colors = ["#ff6", "#a0ffff", "#9f9", "#f99", "#f6f"]; 133 | var wordColor = []; 134 | var colorIdx = 0; 135 | var matchRegex = ""; 136 | var matchingSlides = []; 137 | 138 | this.setRegex = function(input) 139 | { 140 | input = input.replace(/^[^\w]+|[^\w]+$/g, "").replace(/[^\w'-]+/g, "|"); 141 | matchRegex = new RegExp("(" + input + ")","i"); 142 | } 143 | 144 | this.getRegex = function() 145 | { 146 | return matchRegex.toString().replace(/^\/\\b\(|\)\\b\/i$/g, "").replace(/\|/g, " "); 147 | } 148 | 149 | // recursively apply word highlighting 150 | this.hiliteWords = function(node) 151 | { 152 | if(node == undefined || !node) return; 153 | if(!matchRegex) return; 154 | if(skipTags.test(node.nodeName)) return; 155 | 156 | if(node.hasChildNodes()) { 157 | for(var i=0; i < node.childNodes.length; i++) 158 | this.hiliteWords(node.childNodes[i]); 159 | } 160 | if(node.nodeType == 3) { // NODE_TEXT 161 | var nv, regs; 162 | if((nv = node.nodeValue) && (regs = matchRegex.exec(nv))) { 163 | //find the slide's section element and save it in our list of matching slides 164 | var secnode = node; 165 | while (secnode != null && secnode.nodeName != 'SECTION') { 166 | secnode = secnode.parentNode; 167 | } 168 | 169 | var slideIndex = deck.getIndices(secnode); 170 | var slidelen = matchingSlides.length; 171 | var alreadyAdded = false; 172 | for (var i=0; i < slidelen; i++) { 173 | if ( (matchingSlides[i].h === slideIndex.h) && (matchingSlides[i].v === slideIndex.v) ) { 174 | alreadyAdded = true; 175 | } 176 | } 177 | if (! alreadyAdded) { 178 | matchingSlides.push(slideIndex); 179 | } 180 | 181 | if(!wordColor[regs[0].toLowerCase()]) { 182 | wordColor[regs[0].toLowerCase()] = colors[colorIdx++ % colors.length]; 183 | } 184 | 185 | var match = document.createElement(hiliteTag); 186 | match.appendChild(document.createTextNode(regs[0])); 187 | match.style.backgroundColor = wordColor[regs[0].toLowerCase()]; 188 | match.style.fontStyle = "inherit"; 189 | match.style.color = "#000"; 190 | 191 | var after = node.splitText(regs.index); 192 | after.nodeValue = after.nodeValue.substring(regs[0].length); 193 | node.parentNode.insertBefore(match, after); 194 | } 195 | } 196 | }; 197 | 198 | // remove highlighting 199 | this.remove = function() 200 | { 201 | var arr = document.getElementsByTagName(hiliteTag); 202 | var el; 203 | while(arr.length && (el = arr[0])) { 204 | el.parentNode.replaceChild(el.firstChild, el); 205 | } 206 | }; 207 | 208 | // start highlighting at target node 209 | this.apply = function(input) 210 | { 211 | if(input == undefined || !input) return; 212 | this.remove(); 213 | this.setRegex(input); 214 | this.hiliteWords(targetNode); 215 | return matchingSlides; 216 | }; 217 | 218 | } 219 | 220 | return { 221 | 222 | id: 'search', 223 | 224 | init: reveal => { 225 | 226 | deck = reveal; 227 | deck.registerKeyboardShortcut( 'CTRL + Shift + F', 'Search' ); 228 | 229 | document.addEventListener( 'keydown', function( event ) { 230 | if( event.key == "F" && (event.ctrlKey || event.metaKey) ) { //Control+Shift+f 231 | event.preventDefault(); 232 | toggleSearch(); 233 | } 234 | }, false ); 235 | 236 | }, 237 | 238 | open: openSearch 239 | 240 | } 241 | }; 242 | 243 | export default Plugin; -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/zoom/zoom.esm.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * reveal.js Zoom plugin 3 | */ 4 | var e={id:"zoom",init:function(e){e.getRevealElement().addEventListener("mousedown",(function(o){var n=/Linux/.test(window.navigator.platform)?"ctrl":"alt",i=(e.getConfig().zoomKey?e.getConfig().zoomKey:n)+"Key",d=e.getConfig().zoomLevel?e.getConfig().zoomLevel:2;o[i]&&!e.isOverview()&&(o.preventDefault(),t.to({x:o.clientX,y:o.clientY,scale:d,pan:!1}))}))}},t=function(){var e=1,o=0,n=0,i=-1,d=-1,s="WebkitTransform"in document.body.style||"MozTransform"in document.body.style||"msTransform"in document.body.style||"OTransform"in document.body.style||"transform"in document.body.style;function r(t,o){var n=y();if(t.width=t.width||1,t.height=t.height||1,t.x-=(window.innerWidth-t.width*o)/2,t.y-=(window.innerHeight-t.height*o)/2,s)if(1===o)document.body.style.transform="",document.body.style.OTransform="",document.body.style.msTransform="",document.body.style.MozTransform="",document.body.style.WebkitTransform="";else{var i=n.x+"px "+n.y+"px",d="translate("+-t.x+"px,"+-t.y+"px) scale("+o+")";document.body.style.transformOrigin=i,document.body.style.OTransformOrigin=i,document.body.style.msTransformOrigin=i,document.body.style.MozTransformOrigin=i,document.body.style.WebkitTransformOrigin=i,document.body.style.transform=d,document.body.style.OTransform=d,document.body.style.msTransform=d,document.body.style.MozTransform=d,document.body.style.WebkitTransform=d}else 1===o?(document.body.style.position="",document.body.style.left="",document.body.style.top="",document.body.style.width="",document.body.style.height="",document.body.style.zoom=""):(document.body.style.position="relative",document.body.style.left=-(n.x+t.x)/o+"px",document.body.style.top=-(n.y+t.y)/o+"px",document.body.style.width=100*o+"%",document.body.style.height=100*o+"%",document.body.style.zoom=o);e=o,document.documentElement.classList&&(1!==e?document.documentElement.classList.add("zoomed"):document.documentElement.classList.remove("zoomed"))}function m(){var t=.12*window.innerWidth,i=.12*window.innerHeight,d=y();nwindow.innerHeight-i&&window.scroll(d.x,d.y+(1-(window.innerHeight-n)/i)*(14/e)),owindow.innerWidth-t&&window.scroll(d.x+(1-(window.innerWidth-o)/t)*(14/e),d.y)}function y(){return{x:void 0!==window.scrollX?window.scrollX:window.pageXOffset,y:void 0!==window.scrollY?window.scrollY:window.pageYOffset}}return s&&(document.body.style.transition="transform 0.8s ease",document.body.style.OTransition="-o-transform 0.8s ease",document.body.style.msTransition="-ms-transform 0.8s ease",document.body.style.MozTransition="-moz-transform 0.8s ease",document.body.style.WebkitTransition="-webkit-transform 0.8s ease"),document.addEventListener("keyup",(function(o){1!==e&&27===o.keyCode&&t.out()})),document.addEventListener("mousemove",(function(t){1!==e&&(o=t.clientX,n=t.clientY)})),{to:function(o){if(1!==e)t.out();else{if(o.x=o.x||0,o.y=o.y||0,o.element){var n=o.element.getBoundingClientRect();o.x=n.left-20,o.y=n.top-20,o.width=n.width+40,o.height=n.height+40}void 0!==o.width&&void 0!==o.height&&(o.scale=Math.max(Math.min(window.innerWidth/o.width,window.innerHeight/o.height),1)),o.scale>1&&(o.x*=o.scale,o.y*=o.scale,r(o,o.scale),!1!==o.pan&&(i=setTimeout((function(){d=setInterval(m,1e3/60)}),800)))}},out:function(){clearTimeout(i),clearInterval(d),r({x:0,y:0},1),e=1},magnify:function(e){this.to(e)},reset:function(){this.out()},zoomLevel:function(){return e}}}();export default function(){return e} 5 | -------------------------------------------------------------------------------- /site/website/2022-rust-nyc/plugin/zoom/zoom.js: -------------------------------------------------------------------------------- 1 | !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).RevealZoom=o()}(this,(function(){"use strict"; 2 | /*! 3 | * reveal.js Zoom plugin 4 | */var e={id:"zoom",init:function(e){e.getRevealElement().addEventListener("mousedown",(function(t){var n=/Linux/.test(window.navigator.platform)?"ctrl":"alt",i=(e.getConfig().zoomKey?e.getConfig().zoomKey:n)+"Key",d=e.getConfig().zoomLevel?e.getConfig().zoomLevel:2;t[i]&&!e.isOverview()&&(t.preventDefault(),o.to({x:t.clientX,y:t.clientY,scale:d,pan:!1}))}))}},o=function(){var e=1,t=0,n=0,i=-1,d=-1,s="WebkitTransform"in document.body.style||"MozTransform"in document.body.style||"msTransform"in document.body.style||"OTransform"in document.body.style||"transform"in document.body.style;function r(o,t){var n=l();if(o.width=o.width||1,o.height=o.height||1,o.x-=(window.innerWidth-o.width*t)/2,o.y-=(window.innerHeight-o.height*t)/2,s)if(1===t)document.body.style.transform="",document.body.style.OTransform="",document.body.style.msTransform="",document.body.style.MozTransform="",document.body.style.WebkitTransform="";else{var i=n.x+"px "+n.y+"px",d="translate("+-o.x+"px,"+-o.y+"px) scale("+t+")";document.body.style.transformOrigin=i,document.body.style.OTransformOrigin=i,document.body.style.msTransformOrigin=i,document.body.style.MozTransformOrigin=i,document.body.style.WebkitTransformOrigin=i,document.body.style.transform=d,document.body.style.OTransform=d,document.body.style.msTransform=d,document.body.style.MozTransform=d,document.body.style.WebkitTransform=d}else 1===t?(document.body.style.position="",document.body.style.left="",document.body.style.top="",document.body.style.width="",document.body.style.height="",document.body.style.zoom=""):(document.body.style.position="relative",document.body.style.left=-(n.x+o.x)/t+"px",document.body.style.top=-(n.y+o.y)/t+"px",document.body.style.width=100*t+"%",document.body.style.height=100*t+"%",document.body.style.zoom=t);e=t,document.documentElement.classList&&(1!==e?document.documentElement.classList.add("zoomed"):document.documentElement.classList.remove("zoomed"))}function m(){var o=.12*window.innerWidth,i=.12*window.innerHeight,d=l();nwindow.innerHeight-i&&window.scroll(d.x,d.y+(1-(window.innerHeight-n)/i)*(14/e)),twindow.innerWidth-o&&window.scroll(d.x+(1-(window.innerWidth-t)/o)*(14/e),d.y)}function l(){return{x:void 0!==window.scrollX?window.scrollX:window.pageXOffset,y:void 0!==window.scrollY?window.scrollY:window.pageYOffset}}return s&&(document.body.style.transition="transform 0.8s ease",document.body.style.OTransition="-o-transform 0.8s ease",document.body.style.msTransition="-ms-transform 0.8s ease",document.body.style.MozTransition="-moz-transform 0.8s ease",document.body.style.WebkitTransition="-webkit-transform 0.8s ease"),document.addEventListener("keyup",(function(t){1!==e&&27===t.keyCode&&o.out()})),document.addEventListener("mousemove",(function(o){1!==e&&(t=o.clientX,n=o.clientY)})),{to:function(t){if(1!==e)o.out();else{if(t.x=t.x||0,t.y=t.y||0,t.element){var n=t.element.getBoundingClientRect();t.x=n.left-20,t.y=n.top-20,t.width=n.width+40,t.height=n.height+40}void 0!==t.width&&void 0!==t.height&&(t.scale=Math.max(Math.min(window.innerWidth/t.width,window.innerHeight/t.height),1)),t.scale>1&&(t.x*=t.scale,t.y*=t.scale,r(t,t.scale),!1!==t.pan&&(i=setTimeout((function(){d=setInterval(m,1e3/60)}),800)))}},out:function(){clearTimeout(i),clearInterval(d),r({x:0,y:0},1),e=1},magnify:function(e){this.to(e)},reset:function(){this.out()},zoomLevel:function(){return e}}}();return function(){return e}})); 5 | -------------------------------------------------------------------------------- /site/website/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 aper-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /site/website/README.md: -------------------------------------------------------------------------------- 1 | # website 2 | The Aper project website: https://aper.dev/ 3 | -------------------------------------------------------------------------------- /site/website/_cobalt.yml: -------------------------------------------------------------------------------- 1 | 2 | site: 3 | title: cobalt blog 4 | description: Blog Posts Go Here 5 | base_url: http://example.com 6 | posts: 7 | rss: rss.xml 8 | 9 | syntax_highlight: 10 | theme: "InspiredGitHub" -------------------------------------------------------------------------------- /site/website/_layouts/default.liquid: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ page.title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | {{ page.content }} 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /site/website/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamsocket/aper/647577186b7314a2786b03c64eab425151c5faf1/site/website/favicon.png -------------------------------------------------------------------------------- /site/website/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default.liquid 3 | title: Aper 4 | --- 5 | 11 |

Aper is an MIT-licensed Rust library from Drifting in Space.

12 | 13 | 23 | 24 |
25 |

Every data mutation is a first-class value.

26 |

Serialize them to synchronize state across a network, or persist them to create an audit log.

27 |
28 | 29 | ```rust 30 | // use aper::{StateMachine, data_structures::{List, AtomRc}}; 31 | // // `List` represents an ordered list. 32 | // // `Atom` wraps a value to make it immutable 33 | // // except by replacement. 34 | 35 | // fn main() { 36 | // let mut my_list: List> = List::new(); 37 | 38 | // let (_id, transition) = my_list.append(AtomRc::new( 39 | // "Hello Aper".to_string())); 40 | 41 | // // `transition` represents the action of adding 42 | // // "Hello Aper" to the list, but doesn’t actually 43 | // // modify the data. 44 | 45 | // my_list = my_list.apply(&transition).unwrap(); 46 | 47 | // // Now the transition is applied. 48 | // } 49 | ``` 50 | 51 |
52 | 53 |
54 |

Mutations can be applied out-of-order.

55 |

Mutations encode intent, so concurrent mutations are cleanly applied where possible.

56 |
57 | 58 | ```rust 59 | // use aper::{StateMachine, data_structures::{List, Atom}}; 60 | 61 | // fn main() { 62 | // let mut my_list: List> = List::new(); 63 | 64 | // let (id1, transition1) = my_list.append(Atom::new(1)); 65 | // let (id2, transition2) = my_list.append(Atom::new(2)); 66 | 67 | // my_list = my_list.apply(&transition2).unwrap(); 68 | // // my_list = [2] 69 | 70 | // my_list = my_list.apply(&transition1).unwrap(); 71 | // // my_list = [2, 1] 72 | 73 | // let (_id3, transition3) = my_list 74 | // .insert_between(&id2, &id1, Atom::new(3)); 75 | 76 | // let (_id4, transition4) = my_list 77 | // .insert_between(&id2, &id1, Atom::new(4)); 78 | 79 | // my_list = my_list.apply(&transition4).unwrap(); 80 | // // my_list = [2, 4, 1] 81 | 82 | // my_list = my_list.apply(&transition3).unwrap(); 83 | // // my_list = [2, 4, 3, 1] 84 | // } 85 | ``` 86 | 87 |
88 | 89 |
90 |

Implement arbitrary update logic.

91 |

Define your own units of state that integrate seamlessly with Aper's built-in data structures.

92 |
93 | 94 | ```rust 95 | // use aper::{StateMachine, NeverConflict}; 96 | // use serde::{Serialize, Deserialize}; 97 | 98 | // #[derive(Serialize, Deserialize, Debug, Clone)] 99 | // struct Counter {value: i64} 100 | 101 | // #[derive(Serialize, Deserialize, 102 | // Debug, Clone, PartialEq)] 103 | // enum CounterTransition { 104 | // Add(i64), 105 | // Subtract(i64), 106 | // Reset, 107 | // } 108 | 109 | // impl StateMachine for Counter { 110 | // type Transition = CounterTransition; 111 | // type Conflict = NeverConflict; 112 | 113 | // fn apply(&self, event: &CounterTransition) 114 | // -> Result { 115 | // match event { 116 | // CounterTransition::Add(i) => { 117 | // Ok(Counter {value: self.value + i}) 118 | // } 119 | // CounterTransition::Subtract(i) => { 120 | // Ok(Counter {value: self.value - i}) 121 | // } 122 | // CounterTransition::Reset => { 123 | // Ok(Counter {value: 0}) 124 | // } 125 | // } 126 | // } 127 | // } 128 | ``` 129 | 130 |
-------------------------------------------------------------------------------- /site/website/static/github.svg: -------------------------------------------------------------------------------- 1 | GitHub icon -------------------------------------------------------------------------------- /site/website/static/twitter.svg: -------------------------------------------------------------------------------- 1 | Twitter icon -------------------------------------------------------------------------------- /site/website/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400&family=Source+Code+Pro&display=swap'); 2 | 3 | body { 4 | font-family: Montserrat; 5 | } 6 | 7 | #main { 8 | max-width: 900px; 9 | margin: 30px auto 50px auto; 10 | } 11 | 12 | /* Header */ 13 | 14 | #header #image { 15 | margin-top: 30px; 16 | text-align: center; 17 | } 18 | 19 | #header h1 { 20 | font-size: 40pt; 21 | margin-top: 30px; 22 | } 23 | 24 | @media only screen and (min-width: 900px) { 25 | #header { 26 | display: flex; 27 | } 28 | 29 | #header #text { 30 | max-width: 500px; 31 | } 32 | 33 | #header #image { 34 | text-align: right; 35 | flex: 1; 36 | } 37 | } 38 | 39 | /* Components */ 40 | 41 | pre { 42 | padding: 10px; 43 | border-radius: 5px; 44 | font-size: 120%; 45 | overflow-x: auto; 46 | border: 1px solid rgb(222, 222, 222); 47 | background: #f6f9f9 !important; 48 | } 49 | 50 | @media only screen and (min-width: 900px) { 51 | pre { 52 | float: right; 53 | width: 600px; 54 | } 55 | 56 | .code-caption { 57 | float: left; 58 | width: 240px; 59 | } 60 | 61 | #header h1 { 62 | max-width: 450px; 63 | } 64 | } 65 | 66 | .code-caption { 67 | color: #888; 68 | line-height: 150%; 69 | } 70 | 71 | .code-caption p:first-child { 72 | font-size: 150%; 73 | line-height: 130%; 74 | color: black; 75 | } 76 | 77 | .icons a { 78 | text-decoration: none; 79 | opacity: 0.4; 80 | } 81 | 82 | .icons a:hover { 83 | opacity: 1; 84 | } 85 | 86 | .button { 87 | display: inline-block; 88 | background-color: #8D776D; 89 | border-radius: 5px; 90 | padding: 10px 15px; 91 | margin-right: 10px; 92 | margin-bottom: 10px; 93 | text-decoration: none; 94 | color: #fff; 95 | border-bottom: 1px solid #555; 96 | border-right: 1px solid #555; 97 | } 98 | 99 | .button.primary { 100 | background-color: #223947; 101 | } 102 | 103 | .button svg { 104 | margin-right: 6px; 105 | } 106 | 107 | .button:hover { 108 | background-color: #3786b5; 109 | } 110 | 111 | .button.primary:hover { 112 | background-color: #224f6a; 113 | } 114 | 115 | .buttons { 116 | margin-bottom: 20px; 117 | } 118 | 119 | a { 120 | color: #3786b5; 121 | } 122 | 123 | .explanation { 124 | margin-bottom: 30px; 125 | color: #888; 126 | } 127 | --------------------------------------------------------------------------------