Minimising javascript-side code in a ports module


#1

I’m looking for some help in how best to avoid writing extra code on the javascript-side of a port. I would love a general purpose strategy to take forward, but becuase I can’t explain very clearly what I mean (and because I might just be attempting something stupid!) I’m going to give a specific example of something I’m doing where it came up…

I’m trying to write a ports module to access a subset of PeerJS’s functionality, just for personal use at the moment. Specifically I want to be able to send data between different users. PeerJS provides a broker service that assigns clients an id to find each other and then helps set up a web-RTC connection between them.

However, I’m a bit of a perfectionist and even though the code is just for me I’d like to do it “the right way”, especially as I may end up using the module in multiple projects.

With this in mind I definitely don’t want to just “wrap” the PeerJS functions I need, but instead produce something more Elm-like. After much thinking I decided I wanted an API along the following lines…

A user of the module must first create a type for the data they will be exchanging and create JSON encoders and decoders for it.

They pass these encoders and decoders to a module function to create a RegistrationRequest.

register : (Json.Decode.Decoder data) -> (data -> Json.Encode.Value) -> RegistrationRequest data

Once they have a RegistrationRequest they can use helper functions to add any additional options they may require (e.g. requesting a specific peer id with requireId) and then submitRegistration on the request and waitForCompletion to get a response. On successful registration they get a Broker data back. This is the only way to create a Broker, to prevent accidentally trying to start peer communication before everything is set up.

requireId : String -> RegistrationRequest data -> RegistrationRequest data

submitRegistration : RegistrationRequest data -> Cmd msg

waitForCompletion : RegistrationRequest data -> (Result String (Broker data) -> msg) -> Sub msg

Once the module user has a Broker specific to their data type they can start to connect to other clients, wait for incoming connections and send data across existing connections. This is all done by calling functions on the Broker data they hold which return a Cmd msg. They subscribe to the responses to all these commands by using the subscription they get by invoking responses on their Broker

responses : Broker data -> (Event data -> msg) -> Sub msg

which will return to them an Event everytime something happens:

type Event data
    = Error
    | NewConnection (Connection data)
    | DataReceived Connection (Result String data)
    | ConnectionError (Connection data) String

Note that the Event is specialised according to their chosen data type. There is no need to fill their code with manually calling coders/decoders but rather they get the the result of trying to decode the received data using the decoder they specified at registration.

I was very happy with all this (but feel free to tell me if you think it’s a rubbish API!). However, the problems came when I actually started trying to implement it…

I decided I wanted the module to be as user-friendly as possible. If the connection to the broker drops it should try to reconnect automatically and only issue an Error if it fails. If a Connection to a specific peer goes down it should try to establish a new one for the user, and automatically accept any reconnection attempts from that peer, etc. But in doing all this I found myself contemplating writing more and more javascript on the other side of the port to make it happen.

Now I’ve never learned javascript, I muddle through it based on experience in other languages. I came to Elm specifically because I didn’t want to have to learn all the eccentricities of javascript. And so I couldn’t help thinking, surely it would be safer, easier and more pleasant to be doing all this on the elm side of the port…

But that’s where I hit a problem. The only way I could think of to implement such things on the elm side was to have some kind of event/update loop within the module. I would have to add an extra possibility to my Event type to deal with internal messages like so:

Event data = InternalMsg | ...

and then mandate that the user call

handleInternalMsg : Broker data -> InternalMsg -> ( Broker data, Cmd msg )

and update their Broker and cmd’s accordingly.

But what is to stop a user (or more importantly me, six months later when I want to use the module for something new) from just quickly trying to knock something up in their update loop and forgetting this? E.g.:

update: Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case msg of
        BrokerMsg event ->
            case event of
               DataReceived conn result ->
                   ...

               _ ->
                   -- Oops!  Don't handle InternalMsg
                   ( model, Cmd.none )
        OtherMsgs ->
            ...

I eventually came across the idea of writing an effects manager to let me deal with these messages without any fear of the user forgetting to, but it seems that approach is now frowned upon and won’t be available in 0.19. So is there any other way I can ensure that these messages get handled (other than handling it all on the javascript side of the port)?. Or am I just being paranoid about how careless my future self (or others if I ever decide to share it) will be when using the module?

Any advice on any of this would be greatly appreciated. Sorry for such a long post, but I wanted to follow the advice about the “XY problem” and being specific about what one is trying to achieve!


#2

Answering my own question a bit. I’ve since had an idea for one way to force the user to let the module update it’s internal state if required…

Instead of directly providing an Event data type, the subscription from the Broker could only provide an (opaque) RawMessage

responses : Broker data -> (RawMessage -> msg) -> Sub msg

then in order to access the Event the user could be required to execute:

processMsg : (RawMessage -> msg) -> (Event data -> msg) -> RawMessage -> Broker data -> ( Broker data, Cmd msg )

providing as input a msg to receive any resulting Event and a msg to for any further RawMessage as well as the message to process and then receiving in return a new (potentially updated) copy of the Broker. To thus get access to any results from the Broker the user is now forced into an update loop as follows:

update: Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case msg of
        ReceivedRawMsg rMsg ->
            let
                (newBroker, cmd) =
                    processMsg ReceivedRawMsg BrokerEvent rMsg model.broker
            in
                ( { model | broker = newBroker }, cmd )

        BrokerEvent evt ->
            -- Handle event
            ...

        OtherMsgs ->
            ...

What do people think?


#3

Just in case it slipped through your searches, this discussion had interesting examples of how to make retries automatically by chaining tasks in one unique command. It might help in your quest to reduce the amount of JS code, or it might be useless.


closed #4

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.