Skip to content

add WebSocket endpoint support#66

Open
heshammahamed wants to merge 22 commits into
mainfrom
Add/websocket_support
Open

add WebSocket endpoint support#66
heshammahamed wants to merge 22 commits into
mainfrom
Add/websocket_support

Conversation

@heshammahamed

Copy link
Copy Markdown
Contributor

No description provided.

* 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]] -- .
Comment thread WebPlatform/server.alusus Outdated
socketUrlHandlers.setAt(hPos, handlersRef(index)~no_deref);
def element : ptr[Core.Basic.TiObject] = handlersRef(index)~ptr;

socketUrlHandlers.setAt(hPos, element);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 sarmadka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Comment thread WebPlatform/server.alusus Outdated
.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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can switch back to using ref instead of ptr since you aren't using the broken setAt method.

Comment thread WebPlatform/server.alusus

if !Spp.astMgr.getModifierStringParams(modifier, wsEndpointParam)
|| wsEndpointParam.getLength() != 2 {
System.fail(1, "Invalid WS endpoint params");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread WebPlatform/server.alusus

Spp.astMgr.insertAst(
ast {
if String.isEqual(method, "GET") && String.isEqual(uri, "{{wsEndpointUri}}") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why are we checking specifically for GET requests, rather than all requests hitting this URI?

@heshammahamed heshammahamed Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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):

Quote from MDN.

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.

Comment thread WebPlatform/server.alusus Outdated
setElement("first", ownerRef);
setElement("second", identifier);
};
function extractElementsWithGivenModifier(modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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]] {

Comment thread WebPlatform/server.alusus Outdated
);
}

function extractModifiersParam (elementRef : ref[Core.Basic.TiObject], enName : CharsPtr, arName : CharsPtr, params : ref[Array[String]]){

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Comment thread WebPlatform/server.alusus Outdated
}

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") });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Comment thread WebPlatform/server.alusus Outdated
)},
AstTemplateMap()
.set(Srl.String("wsEndpointUrl"), Core.Basic.TiStr(url))
.set(Srl.String("connectHandler"), firstChildOf(connectHandlerAstBlock))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread WebPlatform/server.alusus Outdated
Comment on lines +697 to +703
ast {
func (connection: ptr[Http.Connection], userData: ptr[Void]): Int {
def output: Int = 0;
block;
return output;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
class wsConnection {
class WsConnection {


class wsConnection {
def _connection : ptr[Http.Connection];
def handshakeInfo : ref[HandshakeInfo];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@heshammahamed heshammahamed Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@heshammahamed

heshammahamed commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

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?

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 WsRoute and WsConnection for now.

WsRoute It holds the handlers that it will be registered to Http.setWebSocket() per endpoint and owns the core logic for handling new connections and incoming data frames: shared per-endpoint state (maxMessageSize, the connected clients list, framing state), frame validation (e.g. rejecting a TEXT frame while still expecting a CONTINUATION frame), reassembling multi-frame messages, adding new connections to clients so users can broadcast message for any connect clients to the endpoint, and managing closing handshakes.

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 on...Wrapper methodes on WsRoute are what actually get passed into setWebSocketHandler and they call the handlers that we get from the user internelly:

setWebSocketHandler(
    // Uri
    instanceFromUserDefinedRoute.onConnectWrapper,
    ...
)

WsConnection will hold the data for the connection itself, so user can send data to that specific connection.

For every new connection, we instantiate a fresh WsConnection and pass it into the user's handlers:

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)?

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