add WebSocket endpoint support#66
Conversation
* find all classes annotated with wsEndpoint modifier * validate and extract the four handler functions (onConnect, onReady, onData, onClose) * extract the URI from the modifier params to pass to setWebSocketHandler
* add wsConnection class with isDead flag and guarded send/close methods * implement constructSocketConnectHandler to call user onConnect with raw connection ptr * implement constructSocketReadyHandler to look up wsConnection and call user onReady * implement constructSocketDataHandler with isBinary flag derived from opcode * implement constructSocketCloseHandler to nullify connection, call user onClose, then remove from map * add socketConnections map to track active connections by ptr[Http.Connection]
* Reference handlers by node pointer (Passage), not by name path * Pass the function node to setWebSocketHandler, not its wrapping block * Keep handler blocks alive across insertAst (avoid use-after-free) * Use ArchInt (cast from connection ptr) as the socketConnections key
…f per-class Previously, a single annotated a class containing one method per handler Handlers are now declared individually, each with its own annotation on a standalone function
…g string to ref -- map[String , ptr[TiObject]] -- .
| socketUrlHandlers.setAt(hPos, handlersRef(index)~no_deref); | ||
| def element : ptr[Core.Basic.TiObject] = handlersRef(index)~ptr; | ||
|
|
||
| socketUrlHandlers.setAt(hPos, element); |
There was a problem hiding this comment.
Map.setAt fails when T2 is a ref[...] type
The bug is in this line inside setAt:
handler this.setAt(i: ArchInt, value: T2): ref[Map[T1, T2]] {
if i < 0 || i >= this.keys.getLength() {
System.fail(1, "Argument `i` is out of range.")
}
this.values(i) = value;
return this
}
this.values(i) returns a ref[T2]. When T2 is itself a ref[...] type, plain = tries to write into whatever that reference currently points to — but if it doesn't point to anything valid yet, this fails/errors, since we need to specify the reference's target before we can assign through it see the Docs about References.
For now i changed my using for Map so instead have T2 as a ref[...] i switch to use ptr[...] data type untill you confirm if it is a real bug and we can see how to solve it.
i think about if we have the T2 is a ref[...] in Map then in the setAt we use ~no_deref operator on the this.value(i) so we make the refrence point to the given value.
There was a problem hiding this comment.
Yes, this is a true but that I'll fix soon, but you are actually not using the setAt function in this PR at all. The set function doesn't have this issue, so you can switch back to using ref instead of ptr.
sarmadka
left a comment
There was a problem hiding this comment.
I've been thinking about how to implement this and I think we need to change the approach back to being class based. Sorry about this, I know you initially went this route then I asked you to change, but now that I thought more deeply about it I think it's better to go back to classes. The main reason I'm thinking of this is so the user can define his own custom WsConnection classes so they can carry custom data alongside the connection. The approach is basically like this:
We define WsConnection in WebPlatform as a base class:
class WsConnection {
def wkThis: WkRef[this_type];
def connection: ptr[Http.Connection];
def isDead : Bool;
handler this.onConnect(...) as_ptr { ... }
handler this.onData(...) as_ptr { ... }
...
}then in user's code:
@webSocket["/api/my-web-socket"]
class MyWsConnection {
@injection def wsConnection: WsConnection;
def myCustomData: MyCustomData;
handler (this:WsConnection).onConnect(...) set_ptr {
...
}
...
}then when we create the web socket handlers in WebPlatform we simply instantiate an instance of the user's class, then call methods on that class. We don't have to manually check for whether the four methods are defined or not because it will automatically default to the parent implementation (from WsConnection) if the user doesn't override that method. The user will then also be able to add any custom data he wants while keeping the custom data strongly typed. Simpler for us to implement, and simpler for the user as well.
Note: the wkThis var at the beginning of WsConnection is useful for grabbing the shared ref to this object. For example, from within the MyWsConnection.onConnect we can do something like:
addMyCustomConnectionObject(SrdRef(this));or something like that. This will require that when we instnatiate an object of type MyWsConnection that we do it using:
newSrdObj[MyWsConnection]I think this approach makes this cleaner, easier to implement, and more solid. What do you think?
| .set(Srl.String("endpointMethod"), Core.Basic.TiStr(endpointParams(0))) | ||
| .set(Srl.String("endpointUri"), Core.Basic.TiStr(endpointParams(1))) | ||
| .set(Srl.String("fullref"), constructElementFullReference(elements(i))) | ||
| .set(Srl.String("fullref"), constructElementFullReference(elements(i)~ptr)) |
There was a problem hiding this comment.
You can switch back to using ref instead of ptr since you aren't using the broken setAt method.
|
|
||
| if !Spp.astMgr.getModifierStringParams(modifier, wsEndpointParam) | ||
| || wsEndpointParam.getLength() != 2 { | ||
| System.fail(1, "Invalid WS endpoint params"); |
There was a problem hiding this comment.
Don't quit the entire program, raise a build notice instead using Spp.buildMgr.raiseBuildNotice so that the developer get a proper build error pointing to the actual line number where the error is.
|
|
||
| Spp.astMgr.insertAst( | ||
| ast { | ||
| if String.isEqual(method, "GET") && String.isEqual(uri, "{{wsEndpointUri}}") { |
There was a problem hiding this comment.
Why are we checking specifically for GET requests, rather than all requests hitting this URI?
There was a problem hiding this comment.
Client handshake request
Even though you're building a server, a client still has to start the WebSocket handshake process by contacting the server and requesting a WebSocket connection. So, you must know how to interpret the client's request. The client will send a pretty standard HTTP request with headers that looks like this (the HTTP version must be 1.1 or greater, and the method must be GET):
So whenever a client wants to start a WebSocket connection, it sends an HTTP GET request to begin the handshake — not any other HTTP method. This is also mandated by RFC 6455 3.1 .
the handshake request must be a GET with no body, since the client is only asking to upgrade the connection, not sending any payload. That's why we check specifically for GET — any other method wouldn't be a valid handshake attempt at all, and should be rejected or treated as a normal HTTP request instead.
| setElement("first", ownerRef); | ||
| setElement("second", identifier); | ||
| }; | ||
| function extractElementsWithGivenModifier(modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] { |
There was a problem hiding this comment.
| function extractElementsWithGivenModifier(modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] { | |
| function findFunctionsWithGivenModifier(modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] { |
| ); | ||
| } | ||
|
|
||
| function extractModifiersParam (elementRef : ref[Core.Basic.TiObject], enName : CharsPtr, arName : CharsPtr, params : ref[Array[String]]){ |
There was a problem hiding this comment.
| function extractModifiersParam (elementRef : ref[Core.Basic.TiObject], enName : CharsPtr, arName : CharsPtr, params : ref[Array[String]]){ | |
| function extractModifiersParams (elementRef : ref[Core.Basic.TiObject], enName : CharsPtr, arName : CharsPtr, params : ref[Array[String]]){ |
Plural, since it's returning all params.
| } | ||
|
|
||
| function classifyWebsocketHandlersWithEndpoint (handlersRef : Array[ref[Core.Basic.TiObject]] , webSocketEndpointsMap : ref[Map[String , Map[String , ptr[Core.Basic.TiObject]]]]) { | ||
| def handlerPossibleNames : Array[String]({ String("onConnect"), String("onReady") , String("onData") , String("onClose") }); |
There was a problem hiding this comment.
| def handlerPossibleNames : Array[String]({ String("onConnect"), String("onReady") , String("onData") , String("onClose") }); | |
| @shared def handlerPossibleNames : Array[String]({ String("onConnect"), String("onReady") , String("onData") , String("onClose") }); |
We can make it static so that we don't re-create the array on each call to this function, since these are non-changing.
| )}, | ||
| AstTemplateMap() | ||
| .set(Srl.String("wsEndpointUrl"), Core.Basic.TiStr(url)) | ||
| .set(Srl.String("connectHandler"), firstChildOf(connectHandlerAstBlock)) |
There was a problem hiding this comment.
You can update constructSocketConnectHandler so that it returns the func element itself, rather than returning a block containing a func, then you can drop the firstChildOf function. That will make the code cleaner.
| ast { | ||
| func (connection: ptr[Http.Connection], userData: ptr[Void]): Int { | ||
| def output: Int = 0; | ||
| block; | ||
| return output; | ||
| } | ||
| }, |
There was a problem hiding this comment.
| ast { | |
| func (connection: ptr[Http.Connection], userData: ptr[Void]): Int { | |
| def output: Int = 0; | |
| block; | |
| return output; | |
| } | |
| }, | |
| (ast func (connection: ptr[Http.Connection], userData: ptr[Void]): Int { | |
| def output: Int = 0; | |
| block; | |
| return output; | |
| }), |
Related to my other comment. This will make the result a func rather than a block containing a func.
| } | ||
| }; | ||
|
|
||
| class wsConnection { |
There was a problem hiding this comment.
| class wsConnection { | |
| class WsConnection { |
|
|
||
| class wsConnection { | ||
| def _connection : ptr[Http.Connection]; | ||
| def handshakeInfo : ref[HandshakeInfo]; |
There was a problem hiding this comment.
Are these handshake info not available in the Http.Connection object already? You are initially copying these values from the connection object, but then you are also keeping a reference to the connection object in this class. Will the connection object not keep this info throughout the life of the connection? If the connection has these info already then we should not have duplicate info. We can add helper methods/properties in WsConnection to grab these info from _connection if needed.
There was a problem hiding this comment.
I copied these fields because wsConnection can outlive the underlying Http.Connection which is stored in _connection — the wrapper stays alive as long as the user holds a reference, even after the connection closes. If we only expose the data via helper methods on _connection, the pointer becomes null after close, so any access after that point whill not return the info data, and the user becomes responsible for saving whatever they need before closing.
The handshake only happens once, at connection setup, so the copied data is small and fixed — not something that grows or changes afterward. Given wsConnection is designed to outlive the connection, I think keeping this data available makes sense as a property of the wrapper itself, rather than pushing that responsibility onto every user of the library.
that is my point of view, i am ready to switch to the helper-method approach if you'd prefer to avoid the copy.
This approach is really really great — much cleaner, and it makes registering handlers a lot easier. But I think we'll need two classes instead of one: let's call them
class WsRoute {
def wkThis: WkRef[this_type];
def maxMessageSize: Int;
def clients: Array[Ref[WsConnection]];
def isFraming: Bool;
// handler wrappers
// one wrapper function for each handle
handler this.onDataWrapper(conn: Http.Connection, ...) {
// Validate the incoming frame against current framing state;
// close the connection on protocol violations.
// Resolve the WsConnection bound to this Http.Connection,
// then hand off to the user handler.
this.onData(Http.getConnectionData(conn), ...);
}
.....
// handlers that the user can over write
handler this.onConnect(conn: Http.Connection) as_ptr { ... }
handler this.onData(conn: WsConnection, ...) as_ptr { ... }
...
}The setWebSocketHandler(
// Uri
instanceFromUserDefinedRoute.onConnectWrapper,
...
)
For every new connection, we instantiate a fresh class WsConnection {
def conn: Http.Connection;
def isDead: Bool;
handler this.send() { ... }
handler this.close() { ... }
}With this split, the user loses the ability to attach custom data. What do you thing the data that the user will need to defined to know if data belong per-connection (WsConnection) or per-route/endpoint (WsRoute)? |
…aram, fix comment typo - Deleted a repeated/duplicate function - Renamed extractModifiersParam to extractModifierParams - Fixed a spelling mistake in a comment
…socket timeout defaults
No description provided.