Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
005e946
add WebSocket endpoint support
heshammahamed Jun 28, 2026
6fa0377
implement WebSocket handler construction
heshammahamed Jun 28, 2026
1d9be51
Fix WebSocket handler generation in server.alusus
heshammahamed Jun 30, 2026
37b4ea0
Generate URI checks for @wsEndpoint paths in requestCallback
heshammahamed Jun 30, 2026
6ea9e7d
Replace shared connection Map with per-connection userData
heshammahamed Jul 5, 2026
66655c3
refactor(websocket): declare endpoint handlers per-function instead o…
heshammahamed Jul 6, 2026
df16361
map string to ptr -- map[String , ptr[TiObject]] -- instead of mappin…
heshammahamed Jul 7, 2026
39a8fee
correct sume bugs in function extractElementsWithGivenModifier inside…
heshammahamed Jul 7, 2026
ac575d0
feat(websocket): make onConnect/onReady/onData/onClose handlers optional
heshammahamed Jul 9, 2026
3fb6208
fix(websocket): remove trailing comma in setWebSocketHandler ast temp…
heshammahamed Jul 10, 2026
9d2d81b
feat(websocket): add handshake info snapshot to wsConnection
heshammahamed Jul 10, 2026
5d079af
feat(websocket): add maxMessageSize, fragmentation reassembly, and cl…
heshammahamed Jul 11, 2026
ca99d38
(websoxket) Capitalize class name from ws_connection to Ws_connection
heshammahamed Jul 11, 2026
76c7679
(websocket) : return back to classes
heshammahamed Jul 14, 2026
ee79a82
(websocket): instantiate user's WsRoute-derived class per endpoint
heshammahamed Jul 15, 2026
39a960a
(server): enable CivetWeb's built-in ping/pong handling
heshammahamed Jul 15, 2026
68d4b32
refactor(server): remove duplicate function, rename extractModifiersP…
heshammahamed Jul 15, 2026
e87eff2
feat(websocket): store close status code and reason for exposure via …
heshammahamed Jul 19, 2026
dc49adf
fix(websocket): segfault in close() methode when a reason message is…
heshammahamed Jul 19, 2026
6edc04b
feat(websocket): handle write failures in send/close, add request/web…
heshammahamed Jul 19, 2026
d9b2c14
feat(websocket): add getStatus() and CLOSING transition to close paths
heshammahamed Jul 19, 2026
ae4d794
fix(websocket): reject unrecognized/reserved opcodes as a protocol error
heshammahamed Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 205 additions & 26 deletions WebPlatform/server.alusus
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,33 @@
);
def i: Int;
for i = 0, i < elements.getLength(), ++i {
def uriParams: Array[String] = getModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي");
def uriParams: Array[String];
extractModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي", uriParams);
if uriParams.getLength() < 1 {
System.fail(1, "Invalid @uiEndpoint params");
}

def titleParams: Array[String] = getModifierParams(elements(i), "title", "عنوان");
def titleParams: Array[String];
extractModifierParams(elements(i), "title", "عنوان", titleParams);
def title: String;
if titleParams.getLength() >= 1 title = titleParams(0) else title = "Alusus WebPlatform";

def iconParams: Array[String] = getModifierParams(elements(i), "icon", "أيقونة");
def iconParams: Array[String];
extractModifierParams(elements(i), "icon", "أيقونة", iconParams);
def icon: String;
if iconParams.getLength() >= 1 icon = iconParams(0);

def appParams: Array[String] = getModifierParams(elements(i), "webApp", "تطبيق_ويب");
def appParams: Array[String];
extractModifierParams(elements(i), "webApp", "تطبيق_ويب" , appParams);
def appManifest: String;
if appParams.getLength() > 0 appManifest = appParams(0);
def appVersion: String;
if appParams.getLength() > 1 appVersion = appParams(1) else appVersion = "v1";

def preCacheFilenames: Array[String] = getModifierParams(elements(i), "preCache", "خزن_مسبق");
def dynCacheFilenames: Array[String] = getModifierParams(elements(i), "dynCache", "خزن_تفاعلي");
def preCacheFilenames : Array[String];
extractModifierParams(elements(i), "preCache", "خزن_مسبق", preCacheFilenames);
def dynCacheFilenames: Array[String];
extractModifierParams(elements(i), "dynCache", "خزن_تفاعلي", dynCacheFilenames);

generateUiEndpointFiles(
elements(i), uriParams(0), title, icon, appManifest, appVersion, preCacheFilenames, dynCacheFilenames,
Expand Down Expand Up @@ -215,7 +221,8 @@
);
def i: Int;
for i = 0, i < elements.getLength(), ++i {
def uriParams: Array[String] = getModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي");
def uriParams: Array[String];
extractModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي" ,uriParams);
if uriParams.getLength() < 1 {
System.fail(1, "Invalid @uiEndpoint params");
}
Expand All @@ -231,18 +238,12 @@
}

func generateBeEndpointsCalls (parent: ref[Core.Basic.TiObject]) {
def elements: Array[ref[Core.Basic.TiObject]] = Spp.astMgr.findElements(
ast { modifier == "beEndpoint" || modifier == "منفذ_بياني" },
parent,
Spp.SeekerFlags.SKIP_OWNERS | Spp.SeekerFlags.SKIP_USES | Spp.SeekerFlags.SKIP_CHILDREN
);
def elements : Array[ref[Core.Basic.TiObject]] = extractFunctionElements(parent, "beEndpoint", "منفذ_بياني");
def i: Int;
for i = 0, i < elements.getLength(), ++i {
def modifier: ref[Core.Basic.TiObject](Spp.astMgr.findModifierForElement(elements(i), "beEndpoint"));
if modifier~ptr == 0 modifier~no_deref = Spp.astMgr.findModifierForElement(elements(i), "منفذ_بياني");
def endpointParams: Array[String];
if !Spp.astMgr.getModifierStringParams(modifier, endpointParams)
|| endpointParams.getLength() < 2 {
extractModifierParams(elements(i), "beEndpoint", "منفذ_بياني" , endpointParams);
if endpointParams.getLength() < 2 {
System.fail(1, "Invalid BE endpoint params");
}
Spp.astMgr.insertAst(
Expand Down Expand Up @@ -275,7 +276,102 @@
);
}
}
func generateWebSocketEndpointChecks (parent: ref[Core.Basic.TiObject]) {
def classElements: Array[ref[Core.Basic.TiObject]] = Spp.astMgr.findElements(
ast { modifier == "wsEndpoint" || modifier == "منفذ_مقبس" },
parent,
Spp.SeekerFlags.SKIP_OWNERS | Spp.SeekerFlags.SKIP_USES | Spp.SeekerFlags.SKIP_CHILDREN
);
def i: Int;
for i = 0, i < classElements.getLength(), ++i {
def modifier: ref[Core.Basic.TiObject](Spp.astMgr.findModifierForElement(classElements(i), "wsEndpoint"));
if modifier~ptr == 0 modifier~no_deref = Spp.astMgr.findModifierForElement(classElements(i), "منفذ_مقبس");

def wsEndpointParam: Array[String];

if !Spp.astMgr.getModifierStringParams(modifier, wsEndpointParam)
|| wsEndpointParam.getLength() != 1 {
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.

}
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.

return 0;
}
},
AstTemplateMap()
.set(Srl.String("wsEndpointUri"), Core.Basic.TiStr(wsEndpointParam(0)))
);
}
}

function generateWebSockets(modulesRef: ref[Core.Basic.TiObject]) {
def webSocketClasses : Array[ref[Core.Basic.TiObject]] = extractTypeElements(modulesRef, "wsEndpoint", "منفذ_مقبس");

if (webSocketClasses.getLength() > 0) {
// register a single set of free-function trampolines that forward each
// callback to the matching bound method on the instance stored in userData,
// since civetweb's C callbacks can't carry an instance pointer directly
Spp.astMgr.insertAst(
ast {
func trampolineConnection (connection: ptr[Http.Connection], userData: ptr[Void]): Int {
def rc: ptr[RefCounter] = userData~cast[ptr[RefCounter]];
def instance: SrdRef[WsRoute](rc~cnt, rc~cnt.managedObj~cast[ptr[WsRoute]]~cnt);
return instance.onConnectWrapper(connection, userData);
}
func trampolineReady (connection: ptr[Http.Connection], userData: ptr[Void]): Void {
def rc: ptr[RefCounter] = userData~cast[ptr[RefCounter]];
def instance: SrdRef[WsRoute](rc~cnt, rc~cnt.managedObj~cast[ptr[WsRoute]]~cnt);
instance.onReadyWrapper(connection, userData);
}
func trampolineData (connection: ptr[Http.Connection], bits: Int, data: CharsPtr, dataLen: ArchWord, userData: ptr[Void]): Int {
def rc: ptr[RefCounter] = userData~cast[ptr[RefCounter]];
def instance: SrdRef[WsRoute](rc~cnt, rc~cnt.managedObj~cast[ptr[WsRoute]]~cnt);
return instance.onDataWrapper(connection, bits, data, dataLen, userData);
}
func trampolineClose (connection: ptr[Http.Connection], userData: ptr[Void]): Void {
def rc: ptr[RefCounter] = userData~cast[ptr[RefCounter]];
def instance: SrdRef[WsRoute](rc~cnt, rc~cnt.managedObj~cast[ptr[WsRoute]]~cnt);
instance.onCloseWrapper(connection, userData);
}
},
AstTemplateMap()
);
}

def i : Int;
for i = 0 , i < webSocketClasses.getLength(), i++ {
def params : Array[String];
extractModifierParams(webSocketClasses(i) , "wsEndpoint", "منفذ_مقبس" , params);

if (params.getLength() != 1) {
System.fail(1 , "where is the endpoint");
}


Spp.astMgr.insertAst(
ast {
def instance: SrdRef[webSocketClass] = SrdRef[webSocketClass].construct();
instance.wkThis.assign(instance);

// keep this route instance alive for the server's lifetime
instance.refCounter.count++;
Http.setWebSocketHandler(
httpContext,
"{{wsEndpointUri}}",
trampolineConnection~ptr,
trampolineReady~ptr,
trampolineData~ptr,
trampolineClose~ptr,
instance.refCounter~ptr~cast[ptr[Void]]
)
},
AstTemplateMap()
.set(Srl.String("wsEndpointUri"), Core.Basic.TiStr(params(0)))
.set(Srl.String("webSocketClass"), constructElementFullReferenceByName(webSocketClasses(i)))
);
}
}
// Querying Functions

func getAssetsRoutesFromModules (modulesRef: ref[Core.Basic.TiObject]): Array[StaticRoute] {
Expand Down Expand Up @@ -326,6 +422,31 @@
func startServer [modulesRef: ast_ref = Root] (
mainAssetsPath: String, uiEndpointsPath: String, options: Array[CharsPtr], useSrcAssetsPath: Bool
): ptr[ServerSession] {

// Bounds how long a single write (over any TCP connection this
// server handles — HTTP or WebSocket) can block on an
// unresponsive/dead peer before giving up — without this, a write can hang for a very long
// time (OS-level TCP timeout) if left unset.
if !(hasOption(options , "request_timeout_ms")) {
options.add("request_timeout_ms");
options.add("5000");
}

// How long the read loop waits for incoming data before sending a PING
// falls back to request_timeout_ms if unset. Used by CivetWeb to detect and
// close unresponsive connections.
if !(hasOption(options , "websocket_timeout_ms")) {
options.add("websocket_timeout_ms");
options.add("10000");
}

// Enables CivetWeb's built-in ping/pong handling for WebSocket
// connections: it auto-replies to client PING frames with PONG
// and filters PONG frames before they reach our data_handler,
// so we don't need to implement this ourselves.
options.add("enable_websocket_ping_pong");
options.add("yes");

// Http library requires that the last options argument is a zero, to denote
// the end of options.
options.add(0);
Expand All @@ -351,6 +472,15 @@
session~cnt~init();
session~cnt.requestCallbackContext = requestCallbackContext;
session~cnt.httpContext = httpContext;
preprocess {
def modules: Array[ref[Core.Basic.TiObject]] = getAllModules(modulesRef~ast);
if modules.getLength() == 0 {
Spp.buildMgr.raiseBuildNotice("SPPA1002", 1, modulesRef~ast);
}
def i: Int;
for i = 0, i < modules.getLength(), ++i generateWebSockets(modules(i));
};

return session;
}

Expand Down Expand Up @@ -414,7 +544,10 @@
Spp.buildMgr.raiseBuildNotice("SPPA1002", 1, modulesRef~ast);
}
def i: Int;
for i = 0, i < modules.getLength(), ++i generateBeEndpointsCalls(modules(i));
for i = 0, i < modules.getLength(), ++i {
generateBeEndpointsCalls(modules(i));
generateWebSocketEndpointChecks(modules(i));
};
};

if String.isEqual(method, "GET") {
Expand Down Expand Up @@ -474,31 +607,76 @@

// Helpers

func hasOption (options: ref[Array[CharsPtr]], key: CharsPtr): Bool {
def i: Int;
// options is a flat key/value list — check every even index (just the keys)
for i = 0, i < options.getLength(), i += 2 {
if String.isEqual(options(i), key){
return true;
}
}
return false;
}

function extractFunctionElements (modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] {
def translations: Map[String, String];
translations.set(String(arName), String(enName));
return Spp.astMgr.findElements(
ast { elementType == "function" },
modulesRef,
Spp.SeekerFlags.SKIP_OWNERS | Spp.SeekerFlags.SKIP_USES | Spp.SeekerFlags.SKIP_CHILDREN,
enName,
translations
);
}

function extractTypeElements (modulesRef: ref[Core.Basic.TiObject], enName: CharsPtr, arName: CharsPtr): Array[ref[Core.Basic.TiObject]] {
def translations: Map[String, String];
translations.set(String(arName), String(enName));
return Spp.astMgr.findElements(
ast { elementType == "type" },
modulesRef,
Spp.SeekerFlags.SKIP_OWNERS | Spp.SeekerFlags.SKIP_USES | Spp.SeekerFlags.SKIP_CHILDREN,
enName,
translations
);
}

function extractModifierParams (elementRef : ref[Core.Basic.TiObject], enKwd : CharsPtr, arKwd : CharsPtr, params : ref[Array[String]]){

def translations: Map[String, String];
translations.set(String(arKwd), String(enKwd));

def modifier: ref[Core.Basic.TiObject](Spp.astMgr.findModifierForElement(elementRef, enKwd,translations));
Spp.astMgr.getModifierStringParams(modifier, params);
}

// refer to an AST element using its pointer instead of referring to that element using an identifier
// makes generating code dynamically easier
func constructElementFullReference (element: ref[Core.Basic.TiObject]): SrdRef[Core.Basic.TiObject] {
return Core.Basic.newSrdObj[Core.Data.Ast.Passage].{

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.

👍

Core.Basic.BindingOf[this].setMember("target", element);
};
}

func constructElementFullReferenceByName (element: ref[Core.Basic.TiObject]): SrdRef[Core.Basic.TiObject] {
def node: ref[Core.Data.Node](castRef[element, Core.Data.Node]);
if node.owner~ptr == 0 return SrdRef[Core.Basic.TiObject]();
def name: String = Spp.astMgr.getDefinitionName(node);
def identifier: SrdRef[Core.Data.Ast.Identifier] = Core.Basic.newSrdObj[Core.Data.Ast.Identifier].{
value.value = name;
};
def ownerRef: SrdRef[Core.Basic.TiObject] = constructElementFullReference(node.owner.owner);
def ownerRef: SrdRef[Core.Basic.TiObject] = constructElementFullReferenceByName(node.owner.owner);
if ownerRef.isNull() return identifier
else return Core.Basic.newSrdObj[Core.Data.Ast.LinkOperator].{
Core.Basic.BindingOf[this].setMember("type", Core.Basic.TiStr("."));
Core.Basic.MapContainerOf[this].{
setElement("first", ownerRef);
setElement("second", identifier);
};
};
}

func getModifierParams (element: ref[Core.Basic.TiObject], enKwd: CharsPtr, arKwd: CharsPtr): Array[String] {
def modifier: ref[Core.Basic.TiObject](Spp.astMgr.findModifierForElement(element, enKwd));
if modifier~ptr == 0 modifier~no_deref = Spp.astMgr.findModifierForElement(element, arKwd);
def endpointParams: Array[String];
Spp.astMgr.getModifierStringParams(modifier, endpointParams);
return endpointParams;
}

func getAllModules (astRef: ref[Core.Basic.TiObject]): Array[ref[Core.Basic.TiObject]] {
def result: Array[ref[Core.Basic.TiObject]];
if astRef~ptr == Root~ast~ptr or Core.Basic.isDerivedFrom[astRef, Spp.Ast.Module] {
Expand Down Expand Up @@ -576,4 +754,5 @@
System.fail(1, String("Invalid asset route; path should end with /: ") + buildPath);
}
}

}
Loading