Search code examples
javascriptvue.jsvuexcyclejs

Cycle.js - Driver - PhoenixJS (Websockets)


We currently have a VueJS application and I am looking at migrating it to Cycle.js (first major project).

I understand in Cycle.JS we have SI and SO for drivers (using adapt()); naturally a WebSocket implementation fits this as it has both read and write effects.

We use Phoenix (Elixir) as our backend using Channels for soft real-time communication. Our client-side WS library is Phoenix herehttps://www.npmjs.com/package/phoenix.

The example on Cycle.js.org is perfect if you know how to connect.

In our case, we authenticate using a REST endpoint which returns a token (JWT) which is used to initialize the WebSocket (token parameter). This token cannot simply be passed into the driver, as the driver is initialized when the Cycle.js application runs.

An example (not actual code) of what we have now (in our VueJS application):

// Code ommited for brevity 
socketHandler = new vueInstance.$phoenix.Socket(FQDN, {
    _token: token
});
socketHandler.onOpen(() => VueBus.$emit('SOCKET_OPEN'));

//...... Vue component (example)
VueBus.$on('SOCKET_OPEN', function () {
    let chan = VueStore.socketHandler.channel('PRIV_CHANNEL', {
        _token: token
    });

    chan.join()
        .receive('ok', () => {
            //... code
        })
})

The above is an example, we have a Vuex store for a global state (socket etc), centralized message bus (Vue app) for communicating between components and channel setups which come from the instantiated Phoenix Socket.

Our channel setup relies on an authenticated Socket connection which needs authentication itself to join that particular channel.

The question is, is this even possible with Cycle.js?

  1. Initialize WebSocket connection with token parameters from a REST call (JWT Token response) - we have implemented this partially
  2. Create channels based off that socket and token (channel streams off a driver?)
  3. Accessing multiple channel streams (I am assuming it may work like sources.HTTP.select(CATEGORY))

We have a 1: N dependency here which I am not sure is possible with drivers.

Thank you in advance,

Update@ 17/12/2018

Essentially what I am trying to imitate is the following (from Cycle.js.org):

The driver takes a sink in, in order to perform write effects (sending messages on a specific channels) but also may return a source; this means there are two streams which are async? Which means that creating the socket at runtime may lead to one stream accessing the "socket" before it is instanitated; please see comments in the snippet below.

import {adapt} from '@cycle/run/lib/adapt';

function makeSockDriver(peerId) {
  // This socket may be created at an unknown period
  //let socket = new Sock(peerId);
  let socket = undefined;

  // Sending is perfect
  function sockDriver(sink$) {
    sink$.addListener({
      next: listener => {

        sink$.addListener({
                next: ({ channel, data }) => {
                    if(channel === 'OPEN_SOCKET' && socket === null) {
                        token = data;

                        // Initialising the socket
                        socket = new phoenix.Socket(FQDN, { token });
                        socketHandler.onOpen(() => listener.next({
                            channel: 'SOCKET_OPEN'
                        }));
                    } else {
                        if(channels[channel] === undefined) {
                            channels[channel] = new Channel(channel, { token });
                        }
                        channels[channel].join()
                            .receive('ok', () => {
                                sendData(data);
                            });
                    }
                }
            });
      },
      error: () => {},
      complete: () => {},
    });

    const source$ = xs.create({
      start: listener => {
        sock.onReceive(function (msg) {
            // There is no guarantee that "socket" is defined here, as this may fire before the socket is actually created 
            socket.on('some_event'); // undefined

            // This works however because a call has been placed back onto the browser stack which probably gives the other blocking thread chance to write to the local stack variable "socket". But this is far from ideal
            setTimeout(() => socket.on('some_event'));
        });
      },
      stop: () => {},
    });

    return adapt(source$);
  }

  return sockDriver;
}

Jan van Brügge, the soluton you provided is perfect (thank you) except I am having trouble with the response part. Please see above example.

For example, what I am trying to achieve is something like this:

// login component
return {
    DOM: ...
    WS: xs.of({
        channel: "OPEN_CHANNEL",
        data: {
            _token: 'Bearer 123'
        }
    })
}

//////////////////////////////////////
// Some authenticated component

// Intent
const intent$ = sources.WS.select(CHANNEL_NAME).startWith(null)

// Model
const model$ = intent$.map(resp => {
    if (resp.some_response !== undefined) {
        return {...}; // some model
    }
    return resp;
})

return {
    DOM: model$.map(resp => {
        // Use response from websocket to create UI of some sort
    })
}

Solution

  • first of all, yes this is possible with a driver, and my suggestion will result in a driver that feels quite like the HTTP driver.

    First of all to have some rough pseudo code that where I can explain everything, I might have misunderstood parts of your question so this might be wrong.

    interface WebsocketMessage {
        channel: string;
        data: any;
    }
    
    function makeWebSocketDriver() {
        let socket = null;
        let token = null;
        let channels = {}
        return function websocketDriver(sink$: Stream<WebsocketMessage> {
            return xs.create({
                start: listener => {
                    sink$.addListener({
                        next: ({ channel, data }) => {
                            if(channel === 'OPEN_SOCKET' && socket === null) {
                                token = data;
                                socket = new phoenix.Socket(FQDN, { token });
                                socketHandler.onOpen(() => listener.next({
                                    channel: 'SOCKET_OPEN'
                                }));
                            } else {
                                if(channels[channel] === undefined) {
                                    channels[channel] = new Channel(channel, { token });
                                }
                                channels[channel].join()
                                    .receive('ok', () => {
                                        sendData(data);
                                    });
                            }
                        }
                    });
                }
            });
        };
    }
    

    This would be the rough structure of such a driver. You see it waits for a message with the token and then opens the socket. It also keeps track of the open channels and sends/receives in those based on the category of the message. This method just requires that all channels have unique names, I am not sure how your channel protocol works in that regard or what you want in particular.

    I hope this enough to get you started, if you clarify the API of the channel send/receive and the socket, I might be able to help more. You are also always welcome to ask questions in our gitter channel