yrb-lite 0.1.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,752 @@
1
+ use magnus::{function, method, prelude::*, Error, RString, Ruby, TryConvert, Value};
2
+ use yrs::encoding::read::{Cursor, Read};
3
+ use yrs::sync::protocol::MessageReader;
4
+ use yrs::sync::{Awareness, DefaultProtocol, Message, Protocol, SyncMessage};
5
+ use yrs::updates::decoder::{Decode, DecoderV1};
6
+ use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
7
+ use yrs::{Doc, ReadTxn, Transact};
8
+
9
+ /// Wrapper around yrs Doc.
10
+ ///
11
+ /// Thread safety: `yrs::Doc` is `Send + Sync`. Its `transact()`/`transact_mut()`
12
+ /// acquire an internal RwLock with blocking semantics, so concurrent access from
13
+ /// multiple Ruby threads serializes safely instead of panicking. There's no
14
+ /// interior-mutability wrapper (RefCell and friends): every method opens and
15
+ /// closes its transaction within a single call.
16
+ #[magnus::wrap(class = "YrbLite::Doc", free_immediately, size)]
17
+ struct RbDoc(Doc);
18
+
19
+ /// Wrapper around yrs Awareness (which contains a Doc).
20
+ ///
21
+ /// Thread safety: `yrs::sync::Awareness` keeps client states in a `DashMap`
22
+ /// and exposes everything through `&self`, so it's built for multi-threaded
23
+ /// server use.
24
+ #[magnus::wrap(class = "YrbLite::Awareness", free_immediately, size)]
25
+ struct RbAwareness(Awareness);
26
+
27
+ /// Compile-time proof that the wrapped types are thread-safe. If a future
28
+ /// yrs upgrade makes Doc or Awareness lose Send/Sync, this fails the build
29
+ /// instead of silently shipping a thread-unsafe gem.
30
+ #[allow(dead_code)]
31
+ fn assert_thread_safe() {
32
+ fn is_send_sync<T: Send + Sync>() {}
33
+ is_send_sync::<Doc>();
34
+ is_send_sync::<Awareness>();
35
+ }
36
+
37
+ /// Run `f` with the GVL (Global VM Lock) released, so other Ruby threads,
38
+ /// including ones calling into this extension, can run in parallel.
39
+ ///
40
+ /// Safety rules for the closure:
41
+ /// - It must not touch any Ruby object or call any Ruby API. Inputs are copied
42
+ /// out of Ruby strings before entering, and results are converted to Ruby
43
+ /// objects after returning.
44
+ /// - It must be `Send` (it runs while other threads own the GVL). `&Doc` and
45
+ /// `&Awareness` are fine: both types are `Sync` (asserted above).
46
+ /// - Any doc lock it takes must be acquired and released inside the closure, so
47
+ /// we never reacquire the GVL while holding a yrs lock and can't deadlock on
48
+ /// lock order.
49
+ ///
50
+ /// Panics inside the closure are caught and re-raised (resumed) after the GVL
51
+ /// is reacquired, where magnus converts them to Ruby exceptions.
52
+ fn nogvl<F, R>(f: F) -> R
53
+ where
54
+ F: FnOnce() -> R + Send,
55
+ R: Send,
56
+ {
57
+ use std::ffi::c_void;
58
+ use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
59
+
60
+ struct Ctx<F, R> {
61
+ func: Option<F>,
62
+ result: Option<std::thread::Result<R>>,
63
+ }
64
+
65
+ unsafe extern "C" fn callback<F, R>(arg: *mut c_void) -> *mut c_void
66
+ where
67
+ F: FnOnce() -> R,
68
+ {
69
+ let ctx = &mut *(arg as *mut Ctx<F, R>);
70
+ let func = ctx.func.take().expect("nogvl callback invoked twice");
71
+ ctx.result = Some(catch_unwind(AssertUnwindSafe(func)));
72
+ std::ptr::null_mut()
73
+ }
74
+
75
+ let mut ctx: Ctx<F, R> = Ctx {
76
+ func: Some(f),
77
+ result: None,
78
+ };
79
+ unsafe {
80
+ rb_sys::rb_thread_call_without_gvl(
81
+ Some(callback::<F, R>),
82
+ &mut ctx as *mut Ctx<F, R> as *mut c_void,
83
+ None,
84
+ std::ptr::null_mut(),
85
+ );
86
+ }
87
+ match ctx.result.expect("nogvl callback did not run") {
88
+ Ok(result) => result,
89
+ Err(panic) => resume_unwind(panic),
90
+ }
91
+ }
92
+
93
+ /// Helper to create a binary Ruby string from bytes. Called only with the GVL
94
+ /// held (after the native work finishes), so `Ruby::get` always succeeds.
95
+ fn binary_string(bytes: &[u8]) -> RString {
96
+ let ruby = Ruby::get().unwrap();
97
+ let s = ruby.str_from_slice(bytes);
98
+ let _ = s.enc_associate(ruby.ascii8bit_encindex());
99
+ s
100
+ }
101
+
102
+ /// Copy a Ruby string's bytes so they can be used without the GVL.
103
+ fn copy_bytes(s: RString) -> Vec<u8> {
104
+ unsafe { s.as_slice() }.to_vec()
105
+ }
106
+
107
+ fn runtime_error(msg: String) -> Error {
108
+ Error::new(Ruby::get().unwrap().exception_runtime_error(), msg)
109
+ }
110
+
111
+ // ============================================================================
112
+ // Pure protocol helpers (no Ruby, no GVL); unit-tested in the `tests` module.
113
+ // ============================================================================
114
+
115
+ /// Classify a frame: a non-zero code only for exactly one well-formed message
116
+ /// that consumes the whole buffer (see `RbAwareness::message_kind` for codes).
117
+ fn classify_message(bytes: &[u8]) -> u8 {
118
+ let mut decoder = DecoderV1::new(Cursor::new(bytes));
119
+ let msg = match Message::decode(&mut decoder) {
120
+ Ok(msg) => msg,
121
+ Err(_) => return 0, // empty or malformed
122
+ };
123
+ // Any remaining byte means a second message or trailing garbage.
124
+ if decoder.read_u8().is_ok() {
125
+ return 0;
126
+ }
127
+ match msg {
128
+ Message::Sync(SyncMessage::SyncStep1(_)) => 1,
129
+ Message::Sync(SyncMessage::SyncStep2(_)) | Message::Sync(SyncMessage::Update(_)) => 2,
130
+ Message::Awareness(_) => 3,
131
+ Message::AwarenessQuery => 4,
132
+ _ => 0, // Auth / Custom: not part of our model
133
+ }
134
+ }
135
+
136
+ /// Merge the document-update deltas (Update / SyncStep2 payloads) carried by a
137
+ /// frame into one update, or `None` if the frame carries no document change
138
+ /// (a request, an awareness update, or a no-op handshake SyncStep2).
139
+ fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String> {
140
+ let mut decoder = DecoderV1::new(Cursor::new(bytes));
141
+ let mut updates: Vec<Vec<u8>> = Vec::new();
142
+ for msg in MessageReader::new(&mut decoder) {
143
+ match msg.map_err(|e| e.to_string())? {
144
+ Message::Sync(SyncMessage::Update(u)) | Message::Sync(SyncMessage::SyncStep2(u)) => {
145
+ updates.push(u)
146
+ }
147
+ _ => {}
148
+ }
149
+ }
150
+ let merged = match updates.len() {
151
+ 0 => return Ok(None),
152
+ 1 => updates.pop().unwrap(),
153
+ _ => yrs::merge_updates_v1(&updates).map_err(|e| e.to_string())?,
154
+ };
155
+ let update = yrs::Update::decode_v1(&merged).map_err(|e| e.to_string())?;
156
+ if update.state_vector().is_empty() && update.delete_set().is_empty() {
157
+ return Ok(None); // no-op (e.g. the empty SyncStep2 in an opening handshake)
158
+ }
159
+ Ok(Some(merged))
160
+ }
161
+
162
+ /// Collect the awareness client IDs referenced by a frame's awareness messages.
163
+ fn awareness_client_ids_in(bytes: &[u8]) -> Result<Vec<u64>, String> {
164
+ let mut decoder = DecoderV1::new(Cursor::new(bytes));
165
+ let mut ids = Vec::new();
166
+ for msg in MessageReader::new(&mut decoder) {
167
+ if let Message::Awareness(update) = msg.map_err(|e| e.to_string())? {
168
+ ids.extend(update.clients.keys().copied());
169
+ }
170
+ }
171
+ Ok(ids)
172
+ }
173
+
174
+ // ============================================================================
175
+ // Doc Implementation
176
+ // ============================================================================
177
+
178
+ impl RbDoc {
179
+ /// Create a new Doc with an optional client_id
180
+ fn new(args: &[Value]) -> Result<Self, Error> {
181
+ let doc = if args.is_empty() {
182
+ Doc::new()
183
+ } else {
184
+ let client_id: u64 = TryConvert::try_convert(args[0])?;
185
+ Doc::with_client_id(client_id)
186
+ };
187
+ Ok(RbDoc(doc))
188
+ }
189
+
190
+ /// Get the client ID
191
+ fn client_id(&self) -> u64 {
192
+ self.0.client_id()
193
+ }
194
+
195
+ /// Get the document GUID
196
+ fn guid(&self) -> String {
197
+ self.0.guid().to_string()
198
+ }
199
+
200
+ /// Get the current state vector encoded as bytes
201
+ fn encode_state_vector(&self) -> RString {
202
+ let doc = &self.0;
203
+ let sv = nogvl(move || {
204
+ let txn = doc.transact();
205
+ txn.state_vector().encode_v1()
206
+ });
207
+ binary_string(&sv)
208
+ }
209
+
210
+ /// Encode state as update (optionally diffed against a state vector)
211
+ fn encode_state_as_update(&self, args: &[Value]) -> Result<RString, Error> {
212
+ let sv_bytes: Option<Vec<u8>> = if args.is_empty() {
213
+ None
214
+ } else {
215
+ let sv_string: RString = TryConvert::try_convert(args[0])?;
216
+ Some(copy_bytes(sv_string))
217
+ };
218
+ let doc = &self.0;
219
+ let update = nogvl(move || -> Result<Vec<u8>, String> {
220
+ let sv = match &sv_bytes {
221
+ None => yrs::StateVector::default(),
222
+ Some(bytes) => yrs::StateVector::decode_v1(bytes).map_err(|e| e.to_string())?,
223
+ };
224
+ let txn = doc.transact();
225
+ Ok(txn.encode_state_as_update_v1(&sv))
226
+ })
227
+ .map_err(runtime_error)?;
228
+ Ok(binary_string(&update))
229
+ }
230
+
231
+ /// Apply a V1 update to the document
232
+ fn apply_update(&self, update: RString) -> Result<(), Error> {
233
+ let update_bytes = copy_bytes(update);
234
+ let doc = &self.0;
235
+ nogvl(move || -> Result<(), String> {
236
+ let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
237
+ let mut txn = doc.transact_mut();
238
+ txn.apply_update(update).map_err(|e| e.to_string())
239
+ })
240
+ .map_err(runtime_error)
241
+ }
242
+
243
+ /// Sync step 1: Create a sync message with our state vector
244
+ fn sync_step1(&self) -> RString {
245
+ let doc = &self.0;
246
+ let encoded = nogvl(move || {
247
+ let txn = doc.transact();
248
+ let sv = txn.state_vector();
249
+ Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()
250
+ });
251
+ binary_string(&encoded)
252
+ }
253
+
254
+ /// Sync step 2: Create a sync message with updates for the given state vector
255
+ fn sync_step2(&self, sv_bytes: RString) -> Result<RString, Error> {
256
+ let sv_data = copy_bytes(sv_bytes);
257
+ let doc = &self.0;
258
+ let encoded = nogvl(move || -> Result<Vec<u8>, String> {
259
+ let sv = yrs::StateVector::decode_v1(&sv_data).map_err(|e| e.to_string())?;
260
+ let txn = doc.transact();
261
+ let update = txn.encode_state_as_update_v1(&sv);
262
+ Ok(Message::Sync(SyncMessage::SyncStep2(update)).encode_v1())
263
+ })
264
+ .map_err(runtime_error)?;
265
+ Ok(binary_string(&encoded))
266
+ }
267
+
268
+ /// Handle a sync message and return response (if any)
269
+ /// Returns [message_type, sync_type, response_bytes] or nil
270
+ fn handle_sync_message(&self, data: RString) -> Result<Option<(u8, u8, RString)>, Error> {
271
+ let data_bytes = copy_bytes(data);
272
+ let doc = &self.0;
273
+
274
+ let (msg_type, sync_type, response) =
275
+ nogvl(move || -> Result<(u8, u8, Vec<u8>), String> {
276
+ let msg = Message::decode_v1(&data_bytes).map_err(|e| e.to_string())?;
277
+
278
+ match msg {
279
+ Message::Sync(sync_msg) => match sync_msg {
280
+ SyncMessage::SyncStep1(sv) => {
281
+ // Respond with SyncStep2
282
+ let txn = doc.transact();
283
+ let update = txn.encode_state_as_update_v1(&sv);
284
+ let response = Message::Sync(SyncMessage::SyncStep2(update));
285
+ Ok((0, 0, response.encode_v1()))
286
+ }
287
+ SyncMessage::SyncStep2(update_bytes) => {
288
+ // Apply the update
289
+ let update =
290
+ yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
291
+ let mut txn = doc.transact_mut();
292
+ txn.apply_update(update).map_err(|e| e.to_string())?;
293
+ Ok((0, 1, Vec::new()))
294
+ }
295
+ SyncMessage::Update(update_bytes) => {
296
+ // Apply the update
297
+ let update =
298
+ yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
299
+ let mut txn = doc.transact_mut();
300
+ txn.apply_update(update).map_err(|e| e.to_string())?;
301
+ Ok((0, 2, Vec::new()))
302
+ }
303
+ },
304
+ Message::Awareness(_) => Ok((1, 0, Vec::new())),
305
+ Message::AwarenessQuery => Ok((3, 0, Vec::new())),
306
+ Message::Auth(_) => Ok((2, 0, Vec::new())),
307
+ Message::Custom(tag, _) => Ok((tag, 0, Vec::new())),
308
+ }
309
+ })
310
+ .map_err(runtime_error)?;
311
+
312
+ Ok(Some((msg_type, sync_type, binary_string(&response))))
313
+ }
314
+
315
+ /// Encode raw update bytes as a sync Update message
316
+ fn encode_update_message(&self, update: RString) -> RString {
317
+ let update_bytes = unsafe { update.as_slice() };
318
+ let msg = Message::Sync(SyncMessage::Update(update_bytes.to_vec()));
319
+ binary_string(&msg.encode_v1())
320
+ }
321
+
322
+ }
323
+
324
+ // ============================================================================
325
+ // Awareness Implementation (includes Doc + presence)
326
+ // ============================================================================
327
+
328
+ impl RbAwareness {
329
+ /// Create a new Awareness with an optional client_id
330
+ fn new(args: &[Value]) -> Result<Self, Error> {
331
+ let awareness = if args.is_empty() {
332
+ Awareness::new(Doc::new())
333
+ } else {
334
+ let client_id: u64 = TryConvert::try_convert(args[0])?;
335
+ Awareness::new(Doc::with_client_id(client_id))
336
+ };
337
+ Ok(RbAwareness(awareness))
338
+ }
339
+
340
+ /// Get the client ID of the underlying document
341
+ fn client_id(&self) -> u64 {
342
+ self.0.doc().client_id()
343
+ }
344
+
345
+ /// Get the document GUID
346
+ fn guid(&self) -> String {
347
+ self.0.doc().guid().to_string()
348
+ }
349
+
350
+ /// A standalone SyncStep1 message (the server's state vector). Sent as its
351
+ /// own frame in the opening handshake so providers that parse one message
352
+ /// per frame (e.g. @y-rb/actioncable) handle it correctly.
353
+ fn sync_step1(&self) -> RString {
354
+ let awareness = &self.0;
355
+ let encoded = nogvl(move || {
356
+ let txn = awareness.doc().transact();
357
+ let sv = txn.state_vector();
358
+ Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()
359
+ });
360
+ binary_string(&encoded)
361
+ }
362
+
363
+ /// Create initial sync messages to send when connection opens.
364
+ /// Returns binary data containing SyncStep1 + Awareness update.
365
+ fn start(&self) -> Result<RString, Error> {
366
+ let awareness = &self.0;
367
+ let encoded = nogvl(move || -> Result<Vec<u8>, String> {
368
+ let protocol = DefaultProtocol;
369
+ let mut encoder = EncoderV1::new();
370
+ protocol
371
+ .start(awareness, &mut encoder)
372
+ .map_err(|e| e.to_string())?;
373
+ Ok(encoder.to_vec())
374
+ })
375
+ .map_err(runtime_error)?;
376
+ Ok(binary_string(&encoded))
377
+ }
378
+
379
+ /// Handle incoming message and return response messages (if any).
380
+ /// Returns binary data containing response messages, or empty if no response needed.
381
+ fn handle(&self, data: RString) -> Result<RString, Error> {
382
+ let data_bytes = copy_bytes(data);
383
+ let awareness = &self.0;
384
+
385
+ let encoded = nogvl(move || -> Result<Vec<u8>, String> {
386
+ let protocol = DefaultProtocol;
387
+ let responses = protocol
388
+ .handle(awareness, &data_bytes)
389
+ .map_err(|e| e.to_string())?;
390
+
391
+ if responses.is_empty() {
392
+ return Ok(Vec::new());
393
+ }
394
+
395
+ let mut encoder = EncoderV1::new();
396
+ for msg in responses {
397
+ msg.encode(&mut encoder);
398
+ }
399
+ Ok(encoder.to_vec())
400
+ })
401
+ .map_err(runtime_error)?;
402
+ Ok(binary_string(&encoded))
403
+ }
404
+
405
+ /// Encode an update message for broadcasting changes to peers.
406
+ fn encode_update(&self, update: RString) -> RString {
407
+ let update_bytes = unsafe { update.as_slice() };
408
+ let msg = Message::Sync(SyncMessage::Update(update_bytes.to_vec()));
409
+ binary_string(&msg.encode_v1())
410
+ }
411
+
412
+ /// Get the current state vector encoded as bytes
413
+ fn encode_state_vector(&self) -> RString {
414
+ let awareness = &self.0;
415
+ let sv = nogvl(move || {
416
+ let txn = awareness.doc().transact();
417
+ txn.state_vector().encode_v1()
418
+ });
419
+ binary_string(&sv)
420
+ }
421
+
422
+ /// Encode state as update (optionally diffed against a state vector)
423
+ fn encode_state_as_update(&self, args: &[Value]) -> Result<RString, Error> {
424
+ let sv_bytes: Option<Vec<u8>> = if args.is_empty() {
425
+ None
426
+ } else {
427
+ let sv_string: RString = TryConvert::try_convert(args[0])?;
428
+ Some(copy_bytes(sv_string))
429
+ };
430
+ let awareness = &self.0;
431
+ let update = nogvl(move || -> Result<Vec<u8>, String> {
432
+ let sv = match &sv_bytes {
433
+ None => yrs::StateVector::default(),
434
+ Some(bytes) => yrs::StateVector::decode_v1(bytes).map_err(|e| e.to_string())?,
435
+ };
436
+ let txn = awareness.doc().transact();
437
+ Ok(txn.encode_state_as_update_v1(&sv))
438
+ })
439
+ .map_err(runtime_error)?;
440
+ Ok(binary_string(&update))
441
+ }
442
+
443
+ /// Set local awareness state (JSON string)
444
+ fn set_local_state(&self, json: String) -> Result<(), Error> {
445
+ let awareness = &self.0;
446
+ let value: serde_json::Value =
447
+ serde_json::from_str(&json).map_err(|e| runtime_error(e.to_string()))?;
448
+ awareness
449
+ .set_local_state(value)
450
+ .map_err(|e| runtime_error(e.to_string()))?;
451
+ Ok(())
452
+ }
453
+
454
+ /// Get local awareness state as JSON string (or nil if not set)
455
+ fn local_state(&self) -> Option<String> {
456
+ let awareness = &self.0;
457
+ awareness
458
+ .local_state::<serde_json::Value>()
459
+ .map(|v| v.to_string())
460
+ }
461
+
462
+ /// Clear local awareness state
463
+ fn clear_local_state(&self) {
464
+ let awareness = &self.0;
465
+ awareness.clean_local_state();
466
+ }
467
+
468
+ /// Get awareness update for broadcasting to peers
469
+ fn encode_awareness_update(&self) -> Result<RString, Error> {
470
+ let awareness = &self.0;
471
+ let update = awareness
472
+ .update()
473
+ .map_err(|e| runtime_error(e.to_string()))?;
474
+ let msg = Message::Awareness(update);
475
+ Ok(binary_string(&msg.encode_v1()))
476
+ }
477
+
478
+ /// Apply a raw update to the underlying document
479
+ fn apply_update(&self, update: RString) -> Result<(), Error> {
480
+ let update_bytes = copy_bytes(update);
481
+ let awareness = &self.0;
482
+ nogvl(move || -> Result<(), String> {
483
+ let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
484
+ let mut txn = awareness.doc().transact_mut();
485
+ txn.apply_update(update).map_err(|e| e.to_string())
486
+ })
487
+ .map_err(runtime_error)
488
+ }
489
+
490
+ /// Decode the awareness client IDs referenced by a protocol message
491
+ /// (which may pack several sub-messages together). Sync sub-messages are
492
+ /// ignored. The ActionCable layer uses this to learn which presence
493
+ /// states arrived on a connection, so it can clear them when that
494
+ /// connection closes.
495
+ fn awareness_client_ids(&self, data: RString) -> Result<Vec<u64>, Error> {
496
+ let data_bytes = copy_bytes(data);
497
+ nogvl(move || awareness_client_ids_in(&data_bytes)).map_err(runtime_error)
498
+ }
499
+
500
+ /// Classify a frame for safe routing and relay. Returns a code only when
501
+ /// the frame is exactly one well-formed message that consumes the whole
502
+ /// buffer, so a malformed, truncated, multi-message, or trailing-garbage
503
+ /// frame (which a malicious client could craft to disrupt others if
504
+ /// relayed) is rejected up front:
505
+ /// 0 = drop (malformed, multiple, unknown, or empty)
506
+ /// 1 = sync step1 (a request: respond, do not relay)
507
+ /// 2 = sync step2/update (a document change: record/apply/relay)
508
+ /// 3 = awareness (presence: relay)
509
+ /// 4 = awareness query (a request: respond, do not relay)
510
+ fn message_kind(&self, data: RString) -> u8 {
511
+ let data_bytes = copy_bytes(data);
512
+ nogvl(move || classify_message(&data_bytes))
513
+ }
514
+
515
+ /// Extract the document-update delta carried by a protocol message: the
516
+ /// payloads of any Update or SyncStep2 sub-messages, merged into a single
517
+ /// update. Returns nil if the message carries no document change (for
518
+ /// instance a SyncStep1 request or an awareness update). The strict audit
519
+ /// path records this exact delta before applying it.
520
+ fn update_from_message(&self, data: RString) -> Result<Option<RString>, Error> {
521
+ let data_bytes = copy_bytes(data);
522
+ let merged = nogvl(move || merged_doc_update(&data_bytes)).map_err(runtime_error)?;
523
+ Ok(merged.map(|b| binary_string(&b)))
524
+ }
525
+
526
+ /// Mark the given clients as disconnected and return an awareness protocol
527
+ /// message (null-state, bumped clock) announcing their removal to peers.
528
+ /// Only clients currently known to this Awareness are removed; unknown
529
+ /// IDs are skipped (so we never broadcast phantom removals). Returns an
530
+ /// empty string when nothing was removed.
531
+ fn remove_clients(&self, client_ids: Vec<u64>) -> Result<RString, Error> {
532
+ let awareness = &self.0;
533
+ let encoded = nogvl(move || -> Result<Vec<u8>, String> {
534
+ let mut removed = Vec::new();
535
+ for id in client_ids {
536
+ if awareness.meta(id).is_some() {
537
+ awareness.remove_state(id);
538
+ removed.push(id);
539
+ }
540
+ }
541
+ if removed.is_empty() {
542
+ return Ok(Vec::new());
543
+ }
544
+ let update = awareness
545
+ .update_with_clients(removed)
546
+ .map_err(|e| e.to_string())?;
547
+ Ok(Message::Awareness(update).encode_v1())
548
+ })
549
+ .map_err(runtime_error)?;
550
+ Ok(binary_string(&encoded))
551
+ }
552
+ }
553
+
554
+ // ============================================================================
555
+ // Module Initialization
556
+ // ============================================================================
557
+
558
+ #[magnus::init]
559
+ fn init(ruby: &Ruby) -> Result<(), Error> {
560
+ let module = ruby.define_module("YrbLite")?;
561
+
562
+ // Define error class
563
+ let standard_error: magnus::RClass = ruby.eval("StandardError")?;
564
+ let _error_class = module.define_class("Error", standard_error)?;
565
+
566
+ // Define Doc class
567
+ let doc_class = module.define_class("Doc", ruby.class_object())?;
568
+ doc_class.define_singleton_method("new", function!(RbDoc::new, -1))?;
569
+ doc_class.define_method("client_id", method!(RbDoc::client_id, 0))?;
570
+ doc_class.define_method("guid", method!(RbDoc::guid, 0))?;
571
+ doc_class.define_method(
572
+ "encode_state_vector",
573
+ method!(RbDoc::encode_state_vector, 0),
574
+ )?;
575
+ doc_class.define_method(
576
+ "encode_state_as_update",
577
+ method!(RbDoc::encode_state_as_update, -1),
578
+ )?;
579
+ doc_class.define_method("apply_update", method!(RbDoc::apply_update, 1))?;
580
+ doc_class.define_method("sync_step1", method!(RbDoc::sync_step1, 0))?;
581
+ doc_class.define_method("sync_step2", method!(RbDoc::sync_step2, 1))?;
582
+ doc_class.define_method(
583
+ "handle_sync_message",
584
+ method!(RbDoc::handle_sync_message, 1),
585
+ )?;
586
+ doc_class.define_method(
587
+ "encode_update_message",
588
+ method!(RbDoc::encode_update_message, 1),
589
+ )?;
590
+
591
+ // Define Awareness class
592
+ let awareness_class = module.define_class("Awareness", ruby.class_object())?;
593
+ awareness_class.define_singleton_method("new", function!(RbAwareness::new, -1))?;
594
+ awareness_class.define_method("client_id", method!(RbAwareness::client_id, 0))?;
595
+ awareness_class.define_method("guid", method!(RbAwareness::guid, 0))?;
596
+ awareness_class.define_method("start", method!(RbAwareness::start, 0))?;
597
+ awareness_class.define_method("sync_step1", method!(RbAwareness::sync_step1, 0))?;
598
+ awareness_class.define_method("handle", method!(RbAwareness::handle, 1))?;
599
+ awareness_class.define_method("encode_update", method!(RbAwareness::encode_update, 1))?;
600
+ awareness_class.define_method(
601
+ "encode_state_vector",
602
+ method!(RbAwareness::encode_state_vector, 0),
603
+ )?;
604
+ awareness_class.define_method(
605
+ "encode_state_as_update",
606
+ method!(RbAwareness::encode_state_as_update, -1),
607
+ )?;
608
+ awareness_class.define_method("apply_update", method!(RbAwareness::apply_update, 1))?;
609
+ awareness_class.define_method("set_local_state", method!(RbAwareness::set_local_state, 1))?;
610
+ awareness_class.define_method("local_state", method!(RbAwareness::local_state, 0))?;
611
+ awareness_class.define_method(
612
+ "clear_local_state",
613
+ method!(RbAwareness::clear_local_state, 0),
614
+ )?;
615
+ awareness_class.define_method(
616
+ "encode_awareness_update",
617
+ method!(RbAwareness::encode_awareness_update, 0),
618
+ )?;
619
+ awareness_class.define_method(
620
+ "awareness_client_ids",
621
+ method!(RbAwareness::awareness_client_ids, 1),
622
+ )?;
623
+ awareness_class.define_method("remove_clients", method!(RbAwareness::remove_clients, 1))?;
624
+ awareness_class.define_method(
625
+ "update_from_message",
626
+ method!(RbAwareness::update_from_message, 1),
627
+ )?;
628
+ awareness_class.define_method("message_kind", method!(RbAwareness::message_kind, 1))?;
629
+
630
+ // Define message type constants
631
+ module.const_set("MSG_SYNC", 0u8)?;
632
+ module.const_set("MSG_AWARENESS", 1u8)?;
633
+ module.const_set("MSG_AUTH", 2u8)?;
634
+ module.const_set("MSG_QUERY_AWARENESS", 3u8)?;
635
+ module.const_set("MSG_SYNC_STEP1", 0u8)?;
636
+ module.const_set("MSG_SYNC_STEP2", 1u8)?;
637
+ module.const_set("MSG_SYNC_UPDATE", 2u8)?;
638
+
639
+ Ok(())
640
+ }
641
+
642
+ // ============================================================================
643
+ // Tests for the pure protocol helpers (run with `cargo test`, no Ruby VM)
644
+ // ============================================================================
645
+
646
+ #[cfg(test)]
647
+ mod tests {
648
+ use super::*;
649
+ use yrs::sync::Awareness;
650
+ use yrs::Text;
651
+
652
+ fn text_update(content: &str) -> Vec<u8> {
653
+ let doc = Doc::new();
654
+ let text = doc.get_or_insert_text("content");
655
+ text.insert(&mut doc.transact_mut(), 0, content);
656
+ let update = doc
657
+ .transact()
658
+ .encode_state_as_update_v1(&yrs::StateVector::default());
659
+ update
660
+ }
661
+
662
+ fn update_frame(content: &str) -> Vec<u8> {
663
+ Message::Sync(SyncMessage::Update(text_update(content))).encode_v1()
664
+ }
665
+
666
+ fn step1_frame() -> Vec<u8> {
667
+ Message::Sync(SyncMessage::SyncStep1(yrs::StateVector::default())).encode_v1()
668
+ }
669
+
670
+ fn awareness_frame(client_id: u64) -> Vec<u8> {
671
+ let awareness = Awareness::new(Doc::with_client_id(client_id));
672
+ awareness
673
+ .set_local_state(serde_json::json!({ "user": "alice" }))
674
+ .unwrap();
675
+ Message::Awareness(awareness.update().unwrap()).encode_v1()
676
+ }
677
+
678
+ #[test]
679
+ fn classify_accepts_clean_single_messages() {
680
+ assert_eq!(classify_message(&step1_frame()), 1);
681
+ assert_eq!(classify_message(&update_frame("hi")), 2);
682
+ assert_eq!(classify_message(&awareness_frame(7)), 3);
683
+ assert_eq!(classify_message(&Message::AwarenessQuery.encode_v1()), 4);
684
+ }
685
+
686
+ #[test]
687
+ fn classify_rejects_unsafe_frames() {
688
+ assert_eq!(classify_message(b""), 0, "empty");
689
+ assert_eq!(classify_message(&[0xff, 0xff, 0xff]), 0, "garbage");
690
+ assert_eq!(classify_message(&[0x63, 0x63, 0x63]), 0, "unknown type");
691
+
692
+ let mut two = update_frame("a");
693
+ two.extend(awareness_frame(1)); // two messages packed together
694
+ assert_eq!(classify_message(&two), 0, "multi-message");
695
+
696
+ let mut trailing = update_frame("a");
697
+ trailing.extend_from_slice(&[0xde, 0xad]);
698
+ assert_eq!(classify_message(&trailing), 0, "trailing garbage");
699
+
700
+ let frame = update_frame("hello");
701
+ assert_eq!(classify_message(&frame[..frame.len() / 2]), 0, "truncated");
702
+ }
703
+
704
+ #[test]
705
+ fn merged_doc_update_extracts_and_skips_no_ops() {
706
+ // A document update yields a delta that reconstructs the content.
707
+ let delta = merged_doc_update(&update_frame("hello"))
708
+ .unwrap()
709
+ .expect("a document update");
710
+ let doc = Doc::new();
711
+ doc.transact_mut()
712
+ .apply_update(yrs::Update::decode_v1(&delta).unwrap())
713
+ .unwrap();
714
+ // The delta carried real content, so applying it advances the doc.
715
+ assert!(!doc.transact().state_vector().is_empty());
716
+
717
+ // A SyncStep1 request carries no document change.
718
+ assert!(merged_doc_update(&step1_frame()).unwrap().is_none());
719
+
720
+ // An empty SyncStep2 (no new structs) is a no-op.
721
+ let empty = Message::Sync(SyncMessage::SyncStep2(
722
+ Doc::new()
723
+ .transact()
724
+ .encode_state_as_update_v1(&yrs::StateVector::default()),
725
+ ))
726
+ .encode_v1();
727
+ assert!(merged_doc_update(&empty).unwrap().is_none());
728
+ }
729
+
730
+ #[test]
731
+ fn merged_doc_update_merges_multiple_updates() {
732
+ // Two updates from different clients packed in one frame merge into one.
733
+ let mut frame = update_frame("a");
734
+ frame.extend(update_frame("b"));
735
+ let merged = merged_doc_update(&frame).unwrap().expect("merged update");
736
+
737
+ // The merged update must decode cleanly as a single update.
738
+ assert!(yrs::Update::decode_v1(&merged).is_ok());
739
+ }
740
+
741
+ #[test]
742
+ fn awareness_client_ids_are_collected() {
743
+ assert_eq!(
744
+ awareness_client_ids_in(&awareness_frame(111)).unwrap(),
745
+ vec![111]
746
+ );
747
+ // A document frame has no awareness client ids.
748
+ assert!(awareness_client_ids_in(&update_frame("x"))
749
+ .unwrap()
750
+ .is_empty());
751
+ }
752
+ }