Library issue: yJs synchronisation and client (state) being overwritten

As detailed elsewhere, I am running into issues syncing a slider between notebooks.

While my Stackblitz example demonstrates how a shared slider using yJs can sync seamlessly between multiple clients (open the link in two tabs, side-by-side), a similar implementation on Observable runs into one big issue: the first client’s state is overwritten as soon as a second client interacts with the shared slider.

  • Open Yjs / Ymap on Observable / dleeftink / Observable in two tabs, side-by-side (EDIT: Here is a notebook mirroring the Stackblitz example 1:1)
  • Interact with the slider on the first tab (second tab updates fine), then the second (first tab state is overwritten)
    • E.g. observe how the sliderState becomes undefined after interacting with it in the second tab.

As this implementation works fine on Stackblitz, I am wondering whether the Observable runtime prevents third-party libraries to access websockets/webworkers across domains (e.g. cross scripting between user.static.observableusercontent.com/next/worker.html and observablehq.com) or that other CORS issues prevent yJs from functioning.

Maybe someone can shed some light on whether Observable’s sandboxing prevents libraries like yJs to communicate between clients?

Link to stackblitz source: Shared slider (yJs) - StackBlitz

The fact that you can still modify the shared value from both tabs shows that the contexts can still communicate.

One noticeable difference is that in your Observable notebook no other cell depends on provider. The order in which cells are run is arbitrary, so provider might execute after other cells which rely on it, unless you add an explicit reference.

I would perhaps suggest to add logging for the document level events, like

ydoc = {
  const d = new Y.Doc({gc:true})
  invalidation.then(() => d.destroy())
  d.on('beforeTransaction', console.log)
  d.on('beforeObserverCalls', console.log)
  d.on('afterTransaction', console.log)
  d.on('update', console.log)
  return d
}

and to consume the generator directly to figure out what’s going on:

sliderState = {

  let cycle = new Mutable()
  let renew = event => {
    viewof comp.value = cycle.value = ymap.get('slider')
  }
  
  ymap.observe(renew)
  invalidation.then(()=>ymap.unobserve(renew ) )

  let value
  do {
    value = cycle.generator.next()
    const v = await value.value
    yield ({current: v, value})
  } while(!value.done)
       
}

If you can, you may also want to include an unminified version of the library to ease analysis.

Thank you Fabian for checking, will see if some of the logging and unminified versions turns up some more information.

You are correct that contexts are communicating, albeit in one(1.5?)-way: the first client sends data and the second client listens, while in reverse the second client seems to emit an initial ‘tick’ upon which the first client erases the shared variable set by ymap.set('value', ...).

I have tried various configurations and update patterns, all leading to the same effect, even when using third-party libraries build on yJs such as sync-store that work quite well outside of Observable. Whenever a second client starts interacting with a shared yJs type (array, map or else), the variable in the first client becomes undefined. Maybe unsurprising, this does not happen when each Observable client appends a (device) UID to the variable name.

Regarding the provider being a sink, this was setup by following the yJs documentation, where the provider is defined by new WebrtcProvider('room name', referenceToYDocVariable).

In any case, I’ve drawn up a notebook closer to the Stackblitz example, exhibiting the same issue as before: updates shared from client 1 > 2 are blocked in reverse.

Not close enough I’m afraid, since you rely on Stackblitz to resolve and bundle the packages. If you take a look at the initial console output of your notebook you’ll see several warnings:

image

You may want to try to create a bundle locally that reexports all the necessary API, then include it both in your notebook and on Stackblitz.

Similarly you could try to dynamically import the packages from your notebook in your Stackblitz example.

And lastly you must reference your HTML cells directly, or ensure that the cells that define your elements have run before you initialize ydoc.

It took me until now to realize that new Mutable() comes from Observable’s stdlib, not Yjs. :man_facepalming:

Yes, not sure if its documented somewhere but I find it a quick and succint way of ‘cycling’ through generator functions and/or defining a scoped mutable. In any case, I’ve only included it to display intermittent slider values in the notebook rather than logging them to the console, as I’ve also posted this issue on the yJs community board who may be less familiar with Observable.

The problem isn’t Observable, but the bundles used. Here’s a Stackblitz repro using the imports from your notebook that exhibits the exact same problem: https://web-platform-skvosp.stackblitz.io/ (edit link)

Nice find! Seems like a bundling issue from the looks of it. When trying to use the advised browser import method, some modules seem to not have a default module export (for instance using import('https://unpkg.com/y-webrtc?module')).

Let’s see if there is any easy way to bundle this up elsewhere.

Specifically, simple-peer seems to cause an issue.

Yes, and y-webrtc defines yjs as peer dependency, which is why skypack pulls it in as well. If you want to attempt client-side bundling and are feeling adventurous, you could take a stab at jspm + import maps: Import Map Generator - JSPM2

I felt quite adventurous indeed, a shared state notebook with esmap based modules is now up and running.

Thanks again for the pointers Fabian!

Nice work!

There’s a few things I would recommend to change or add. Are you open to suggestions?

1 Like

Please do! There is probably a better way to ‘hot-load’ importmaps…

Thanks for the suggestions, merged them in. As @tomlarkworthy noted, the signalling server unfortunately has issues connecting clients under certain conditions; this happens even when switching to the dependencies used in the official documentation.

Not sure where to start hunting this one down, as I am not that familiar with Webrtc; will try and investigate the peerOpts used by simple-peer.

There are still a couple of questions that I’d love to see answered:

  1. Adding the static module requires the use of a proxy iframe. That iframe has to be kept around because the imported libraries now reference the builtins of that browsing context. Can we call importShim() instead with a dynamic import, and keep the entire import handling in the parent frame?
  2. Debug output is only logged to the dev tools console. Is there a way to attach custom consoles instead (they’re referenced as vconsoles in the source) so that we can access the debug information directly?
  3. To figure out wether there are any connected peers you have to observe the debug messages. Can we somehow access that information directly and expose it to the user?

To 1&2, I’d need to experiment some more, but it may require bringing in some of the parent environment variables needed by the modules in the proxy. Re: 3, I encountered two error messages pointing to the simple-peer library when a remote connection is established:

  1. ERR_SET_REMOTE_DESCRIPTION

Error: Failed to execute ‘setRemoteDescription’ on ‘RTCPeerConnection’: Failed to set remote answer sdp: Called in wrong state: stable

  1. ERR_CONNECTION_FAILURE

Error in connection to <GUID>

In another session, the Observable runtime produced an error for the y-webrtc module at new Websocket( wsclient.url ).

Maybe pointing to cross-origin issues after all? Will keep exploring!

I can answer question 2. The short answer is “no”. The longer answer is “kind of, but it’s not pretty”.

Option 1: Override console

Because we instantiate the modules inside an iframe, we’re free to do with that scope as we please. That means we can shim console:

mutable log = []
proxyConsole = ({
  _push(type, args) {
    mutable log.push([type, ...args]);
    mutable log = mutable log;
  },
  debug(...args) { this._push('debug', args) },
  info(...args) { this._push('info', args) },
  log(...args) { this._push('log', args) },
  warn(...args) { this._push('warn', args) },
  error(...args) { this._push('error', args) },
})
// ...
    proxy.contentWindow.console = proxyConsole;
// ...

However, the logging messages are heavily formatted:

Array(6) [
  0: "log"
  1: "%cy-webrtc: %cbroadcast message in %cobservable-shared-state%c +34ms"
  2: "color:green;"
  3: "color:black;"
  4: "color:black;font-weight:bold;"
  5: "color:green;font-weight:normal;"
]

I guess one could strip the color formatters (%c) though, assuming there’s no other kind of formatting (or value placeholders) being used.

Option 2: Virtual console

lib0/logging hardcodes its reference to console, but allows one to instantiate “virtual consoles”, which are basically consoles rendered to a DOM. For this to work we must reference the exact same module instance that is pulled in by y-webrtc.

First we need to obtain the module instance, by modifying our proxy module. I had to reference the full path instead of “lib0/logging”, so I decided to play it safe and pull the path directly from the import map:

 // ...
import { VConsole } from "${esmap.scopes['https://ga.jspm.io/']['lib0/logging']}";
 window.__resolveModules({ Y, WebrtcProvider, VConsole });

Then we create an instance and add auto-scrolling to our wrapping element:

{
  const element = html`<div style="height:20em;font-size:14px;overflow:auto">`;
  const observer = new MutationObserver(() => element.scrollTo(0, element.scrollHeight));
  observer.observe(element, {childList: true});
  const vconsole = new module.VConsole(element);
  invalidation.then(() => vconsole.destroy());
  return element;
}

which gives us

We can access the current number of peers via

provider.room.webrtcConns.size

webrtcConns is a map of WebrtcConn instances, keyed by peerId:

It should suffice to query/update that list every few seconds. I haven’t found a way yet to subscribe to broadcast events (haven’t looked too hard either).

The y-webrtc source is surprisingly easy to follow and worth a look if one wants to learn about the available API: y-webrtc/y-webrtc.js at v10.2.3 · yjs/y-webrtc · GitHub

Awesome! Should come in handy in quite a few situations. I was also getting these messages in my native console (Chrome), weren’t they displaying on your end? (EDIT: Scratch that, didn’t fully read your post). In any case, we can build on this.

Regarding the cellular issues, it is apparently well-known (I didn’t) that Websockets can be finicky over cellular networks if the provider blocks certain ports or adds intermediate proxies. wss with ssl should solve it (the yJs signaling servers use wss), but maybe this is something worth looking into as well.

I think the Awareness API also covers quite some ground re:observing client status.