Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

websockets #72

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft

websockets #72

wants to merge 20 commits into from

Conversation

bgwdotdev
Copy link
Contributor

@bgwdotdev bgwdotdev commented May 11, 2024

Here's a first pass, scrappy implementation of mists websockets into wisp. #10

Still figuring out if there's a better way to do this and discussing with rawhat a little on approaches.

This currently works though. Feedback on approach most welcome.

Option was only added due to the test stuff, not sure how do deal with that yet but maybe that's solving the wrong problem.

Copy link
Collaborator

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! This couples Wisp very tightly to Mist, which it carefully does not do so far. We'll need to decouple it, I've left some notes inline.

src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Show resolved Hide resolved
src/wisp.gleam Outdated Show resolved Hide resolved
@bgwdotdev
Copy link
Contributor Author

I've now created a pure wisp public api for this which mostly wraps the mist types.

Docs (and likely type names/api) still need work but wanted to verify if the approach is more suitable for wisp before attempting to polish things.

Hopefully the Socket type would allow wrapping various connection types that may be required by potentially different backends as appropriate?

Let me know if I'm on the right track or missing a beat.

Thanks :)

Copy link
Collaborator

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!! I like how this is coming along.

There's still some dependency of Mist that needs to be removed. Soon the Mist specific code will be moved to another package, so there can be no dependency here.

Another problem to solve is how do we make it so a Wisp application can only upgrade to a websocket connection if the webserver being used supports it? For example, Elli and CGI don't support websockets, so the programmer must not have the ability to construct a websocket response in those cases. Perhaps it could be that there's some secondary capability value that gets passed in by websocket capable servers, and that is need to upgrade within the application. Whatever the solution the same pattern will need to be usable for server sent events too.

Another thing is how do we write tests for websocket using applications too.

Is there any other problems to solve or other considerations?

src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Outdated Show resolved Hide resolved
@bgwdotdev
Copy link
Contributor Author

bgwdotdev commented Jun 18, 2024

Okay. Had a big stab at moving all the mist stuff into a separate module.

I've fully removed the import mist from the main wisp.gleam file.

Websockets are also now implemented with a reference example in the test file.

I've had to make a couple things in the wisp.gleam file public that I'm not sure we want to keep that way (mostly around make_connection).

Everything needs a tidy, documentation and the test cases fleshed out but hopefully it's looking more like how we want it to be ^^?

Definitely not finished working on it (want to try dump mist from internal somehow) but keen to take any feedback generally at this stage!

gleam.toml Outdated Show resolved Hide resolved
@bgwdotdev
Copy link
Contributor Author

bgwdotdev commented Jun 25, 2024

Okay! I think I'm feeling happy with the general api design and split of things now and was thinking of moving on to docs.

I was thinking of creating a websocket error type within wisp as well for when wisp_mist.send fails, though looking at the errors provided from mist via glisten, I'm thinking we might want to just cover important ones like Closed and Timeout maybe and then the others can fall under some more generic socket error that left for the user to handle? (here's the variants for reference https://hexdocs.pm/glisten/3.0.0/glisten/socket.html#SocketReason)

@bgwdotdev
Copy link
Contributor Author

bgwdotdev commented Jun 26, 2024

left to do:

  • update examples to use wisp_mist.handler
  • write example using websocket

done?

Copy link
Collaborator

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I've left a handful of notes inline.

It's getting a bit hard to follow this PR. If we want to have the Mist code split out into a new module I think it would be good to have that in a first PR and then the websocket stuff in a second. One change-per-PR makes it a lot easier to review and merge

gleam.toml Outdated Show resolved Hide resolved
src/wisp.gleam Outdated Show resolved Hide resolved
@@ -0,0 +1,84 @@
import gleam/bit_array
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this module please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/wisp/wisp_mist.gleam Show resolved Hide resolved
test/mist_test.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Show resolved Hide resolved
src/wisp.gleam Outdated
// Websockets
//

/// The messages possible to receive to and from a websocket handler.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't these only come from the client?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the WsCustom type is called when messages are sent to the optional subject/selector created during the websocket handler on_init. This is how you generally send messages from within your application to a connected websocket.

For example, your server might have a broadcast_clients function that sends a message to all websocket handlers subject, which it will receive on WsCustom, and then maybe you forward that message onto the connected websocket(s) themselves! If that makes sense?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I mean this says the handler sends these, but it receives them

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, I see what you mean, of course! Corrected this now :)

/// }
/// ```
pub fn handler(
handler: fn(wisp.Request, wisp.Ws(mist.Connection)) -> wisp.Response,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mist specific types leak into the application here. A Wisp websocket application should be able to be run on any web socket capable web server without modification, but in this case the type would need to be changed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can wrap the mist.Connection in a wisp_mist.Connection type to hide the mist implementation but I don't think it's possible to remove this altogether as this holds the http socket information which, when a request is made to a websocket, it uses to upgrade the socket to a websocket and then hold onto it to keep the connection alive (to my understanding).

This socket connection implementation will likely be unique per websocket server's approach to managing the connection.

Unless I'm missing something, there's no way to hide this without at least wisp/internal depending on mist/whatever-other-server.

My thought was if you need to swap wisp_mist for whatever, this would involve changing.

wisp.Ws(wisp_mist.Connection) => wisp.Ws(other_server.Connection)

or maybe if they were imported with an alias, it wouldn't change the api :?

import wisp_mist as server => import other_server as server

wisp.Ws(server.Connection)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still specific server implementations leaking into Wisp core, making Wisp applications no longer web server agnostic. It's a hard requirement for the same application to be deployable on different web servers with the same capabilities.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, no worries! I will see what I can figure out :)!

Copy link
Contributor Author

@bgwdotdev bgwdotdev Jul 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latest changes now stop these types from leaking but I can't find a way to do it without the handler function at least returning a function which takes in the required functions to start a websocket actor:

fn(wisp.Request, wisp.WebsocketHandler(a,b)) -> wisp.Response

The problem with this design is you can then only have one solid type for your applications websockets that you need to specific in your handler function.

Spent a lot of time on this and can't seem to find a way around it. Might just need to sleep on it but if you have any ideas, it'd be very welcome ^^.

Any other option I think of just seem to lead to mist leaks :<

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outside of using coerce, which works but obvious is theoretically unsafe

src/wisp.gleam Outdated
type WebsocketConnection(c) =
internal.WebsocketConnection(c)

/// For web socket capable servers to connect to clients
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this is from the documentation, could it be expanded please 🙏

The name isn't very clear either. It doesn't appear to be a websocket?

Copy link
Contributor Author

@bgwdotdev bgwdotdev Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hopefully makes more sense now ^^? (I do think these need renamed but not figured good names out yet)

test/mist_test.gleam Outdated Show resolved Hide resolved
@bgwdotdev
Copy link
Contributor Author

Thanks for the feedback+review, been busy with work so not been able to progress it, off next week though so will try push this along to finish line and address all your notes :)! Much appreciated.

@bgwdotdev bgwdotdev mentioned this pull request Jul 23, 2024
@bgwdotdev
Copy link
Contributor Author

the mist module split only has now been created as another pr ahead of the work here if we want to tackle that bit first :)

@bgwdotdev
Copy link
Contributor Author

just as a note, rebase off main now so diff should be easier

Copy link
Collaborator

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I've left a series of questions

@@ -0,0 +1,92 @@
import app/chatroom
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this example please. It works but it's not the best way to implement this and has some failure cases which can leak memory or otherwise go wrong, and I wouldn't want people to copy it rather than studying OTP to figure out a more reliable way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, makes sense, deleted this. Perhaps worth us revisiting at a later date to make a quality example as seems lots of folks look for a reference on this type of thing :)

src/wisp.gleam Outdated
// Websockets
//

/// The messages possible to receive to and from a websocket handler.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I mean this says the handler sends these, but it receives them

/// Upgrades the socket to a websocket, is only used internally by the
/// web server and should not be called directly.
///
Websocket(process.Selector(process.ProcessDown))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it contain process.Selector(process.ProcessDown)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I'm not sure -- I don't really understand the underlying otp stuff going on here ^^; I'll try to look into it to understand it more but all I know is that if you remove it, the websocket just automatically closes after the first message

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? Types don't change runtime behaviour like that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, not sure, could be bad memory on my part, I'll give it another test and see :)

src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Outdated
/// This type will need to be passed to your webserver of choice websocket
/// function, such as `wisp_mist.websocket`.
///
pub type WebsocketHandler(state, msg) {
Copy link
Collaborator

@lpil lpil Aug 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some places its Ws, some it's Websocket. Which do we want? Normally we don't abbreviate in Gleam, but on the other hand this word would be used a lot.

Copy link
Contributor Author

@bgwdotdev bgwdotdev Aug 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, I've now standardised this to WsXyz format. I'm happy to go either way on this though. I feel WebsocketXyz get really cumbersome though that may just be because I've been writing a lot of variations of it while trying to figure out an api hah.

I would entirely defer to your preference on this and happy to change as appropriate. The only other though I has was moving the websocket stuff into a wisp/websocket.gleam module and perhaps removing Ws/Websocket entirely from all the names?

src/wisp.gleam Outdated Show resolved Hide resolved
src/wisp.gleam Outdated
/// active websocket (`WebsocketConnection`).
///
pub type WsCap(state, msg) {
WsCap(fn(Request, WebsocketHandler(state, msg)) -> Response)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this inner value for? I couldn't see it used anywhere

Copy link
Contributor Author

@bgwdotdev bgwdotdev Aug 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, the examples had fallen out of line with the new api, it was present in the test but I've actually added a new wisp.websocket function which I think better shows how this is used. I've now updated all the test/examples to use this

src/wisp.gleam Outdated Show resolved Hide resolved
@lpil lpil marked this pull request as draft August 24, 2024 12:34
@bgwdotdev
Copy link
Contributor Author

Ok, I think I've updated everything! docs, examples, tests hopefully all covered :)

Assuming there was no further changes required, the only issue left I think would be how we want to do errors. I'm not happy with the error handling I've added but also not sure we want to copy all the errors available in the mist implementation. If you have an idea on how you'd prefer this was present to the user, happy to follow that :)

otherwise if just translating over all the glisten socket errors makes sense then happy to do that as well:
https://hexdocs.pm/glisten/3.0.0/glisten/socket.html#SocketReason

/// Upgrades the socket to a websocket, is only used internally by the
/// web server and should not be called directly.
///
Websocket(process.Selector(process.ProcessDown))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? Types don't change runtime behaviour like that.

/// send data to the client via `WsSendText` or `WsSendBinary`
///
type WsConnection =
fn(WsSend) -> Result(Nil, WsError)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a connection a function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was changed into a callback to avoid leaking the mist websocket connection type or creating a mist specific implementation of send due to the types required.

To avoid this it is we return a callback from our mist on_init wrapper that has the connection hidden away inside it, so you just pass your data to the WsConnection type and it takes care of attaching the mist (or otherwise) connection type, on your behalf.

Maybe another name would make more sense? I'm not able to see another api design to get around this problem if we don't want it to be a function unfortunately

/// function. It is required to turn a http connection into an
/// active websocket (`WsConnection`).
///
pub type WsCapability(state, msg) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the internals of this be public? How would this dissuade users from constructing it directly?

Why does the capability have type parameters and contain the handler? Do you want to restrict the websocket processes to have specific messages and state for other implementations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the internals of this be public?

Due to a circular import issue, I was unable to put this into internal (as then internal would depend on request and response from wisp.gleam and wisp.gleam would depend on internal.gleam to export it as an opaque type) but it is required to be construct-able by mist_wisp or other server implementations -- I'll look into seeing if I can create another internal module and have it act as a middle man or diamond configuration or whatever the correct term for that is ^^;

How would this dissuade users from constructing it directly?

In the current implementation, to construct it you need to import internal which I was hoping would be enough to dissuade folks.

Why does the capability have type parameters and contain the handler? Do you want to restrict the websocket processes to have specific messages and state for other implementations?

This ending up being an unfortunate side-effect of being the only way I could find to remove any mist types leaking due to the need to store the mist connection info for the socket. This way we can essentially hide the mapping of wisp.Request -> mist.Connection -> wisp.Response but it means the capability needs to return a callback which takes in the single extra arg of the websocket handler.

I actually don't really want it to work like this at all as it causes awkwardness if you want to use more than one websocket handler in your wisp server at once but I'm unable to see any other approach to dealing with this in the wisp_mist.handler

WsSendBinary(binary: BitArray)
}

/// TODO: Placeholder error type, flesh out.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this done?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from previous message for your consideration (as I feel like you wouldn't want to copy all those socket errors but I'm not sure, if you have a quick ye/naa confirmation that works for me)

the only issue left I think would be how we want to do errors. I'm not happy with the error handling I've added but also not sure we want to copy all the errors available in the mist implementation. If you have an idea on how you'd prefer this was present to the user, happy to follow that :)

otherwise if just translating over all the glisten socket errors makes sense then happy to do that as well:
https://hexdocs.pm/glisten/3.0.0/glisten/socket.html#SocketReason

WsErrClosed
WsErrTimeout
WsErrTerminated
WsErrOther(String)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No abbreviations please. Put Error at the end of the names.

What's Other for? Document these please.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No abbreviations please. Put Error at the end of the names.

no bother, will do

What's Other for? Document these please.

for above noted discussion on error approach, will adjust accordingly

pub fn handler(
handler: fn(wisp.Request) -> wisp.Response,
handler: fn(wisp.Request, wisp.WsCapability(state, msg)) -> wisp.Response,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No breaking changes to this API please. We said we'd have a new function here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, apologies, I had it in my backwards in my head for whatever reason, will fix

/// A type used purely to block the user constructing a `wisp.WsCapability`
/// via the public api as it should only be constructed by the web server if it
/// supports websockets.
pub type WsCapability {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be opaque

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted earlier due to issues of circular import and needing to construct this in wisp_mist.gleam it is not opaque, will see if I can shuffle the module around to allow this

/// "pong" |> wisp.WsSendText |> conn()
/// ```
///
pub type WsSend {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason not to always sent bit arrays?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No reason I'm aware of, will give it a test and see :)

@lpil
Copy link
Collaborator

lpil commented Oct 9, 2024

Have just been giving this a try. How does one create more than one webhook handler? The type parameter means they have to all have the same types, so I can't create a second one for another route.

@bgwdotdev
Copy link
Contributor Author

Yeah, this is exactly the big problem and sticking point I can't find away around in this ( or any I can think of :( ) design without leaking mist types into wisp/user applications.

Essentially, you'd have to make each websockets type present in a single sum type for each the message and state as we're currently forced to lock in the WsCapability type in the handler.

I explored "safely" casting the types under the hood which while it worked and I believe was safe from being able to be misused, as you've previously stated and I do agree with, it does undermine the type system and a solution away from this would be the way to go.

Unfortunately I can't seem to find a way to set this type any later or earlier than the handler without leaking mists connection. It's probably a skill issue but I've gone over it and over it many times to no avail sadly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants