diff --git a/WebPlatform.alusus b/WebPlatform.alusus index 2bf0128..1807211 100644 --- a/WebPlatform.alusus +++ b/WebPlatform.alusus @@ -31,12 +31,14 @@ import "Spp/Ast"; import "Build"; import "Apm"; import "closure"; +Apm.importPackage("Alusus/Sle@0.2", "Srl/enums.alusus"); Apm.importPackage("Alusus/Http@0.3"); Apm.importPackage("Alusus/Json@0.2"); Apm.importPackage("Alusus/MarkdownTranslator@0.1"); Apm.importPackage("Alusus/Promises@0.1"); import "WebPlatform/server"; +import "WebPlatform/WsConnection"; import "WebPlatform/browser_api"; import "WebPlatform/frontend_helpers"; import "WebPlatform/Styling/Color"; diff --git a/WebPlatform/WsConnection.alusus b/WebPlatform/WsConnection.alusus new file mode 100644 index 0000000..141d3dd --- /dev/null +++ b/WebPlatform/WsConnection.alusus @@ -0,0 +1,212 @@ +@merge module WebPlatform { + class WsStatus { + setupStringEnum[]; + enumStringValue[CONNECTING, "connecting"]; + enumStringValue[OPENED, "opened"]; + enumStringValue[CLOSING, "closing"]; + enumStringValue[CLOSED, "closed"]; + } + + class WsConnection { + def wkThis: WkRef[this_type]; + def connection: ptr[Http.Connection]; + + def status: WsStatus; + def closeCode: word[16] = 1006; + def closeReason: String; + + def maxMessageSize: ArchInt = 1024; + def fragmentBuffer: StringBuilder(); + def fragmentOpcode: Int; + def isFragmenting: Bool = 0; + + handler this~init(conn: ptr[Http.Connection]) { + this.connection = conn; + this.status = WsStatus.CONNECTING; + } + + handler this.getStatus(): WsStatus { + return this.status; + } + + handler this.setMaxMessageSize(size: ArchInt) { + this.maxMessageSize = size; + fragmentBuffer.bufferGrowSize = (maxMessageSize / 2)~cast[ArchInt]; + } + + handler this.getMaxMessageSize(): ArchInt{ + return this.maxMessageSize; + } + + handler this.sendText(data: CharsPtr) { + if this.status != WsStatus.OPENED return ; + + if Http.writeTextToWebSocket(this.connection, data) <= 0 { + this.status = WsStatus.CLOSED; + } + } + + handler this.sendBinary(data: CharsPtr, dataLen: ArchWord) { + if this.status != WsStatus.OPENED return ; + if Http.writeBinaryToWebSocket(this.connection , data , dataLen) <= 0 { + this.status = WsStatus.CLOSED; + } + } + + handler this.close () { + this.close(1000 , "") + } + + handler this.close (statusCode : word[16] , reasonMessage : CharsPtr) { + if this.status != WsStatus.OPENED return; + + this.closeCode = statusCode; + this.closeReason = reasonMessage; + + def reasonLen : Int = String.getLength(reasonMessage); + + def payloadLen : Int = 2 + reasonLen; + + if (payloadLen > 125) { + reasonLen = 123; // 125 - 2 bytes for the status code + payloadLen = 125; + } + + // close frame payload max is 125 bytes as stated in RFC 6455 §5.5.1 + def payload : array[Char, 125]; + + payload(0) = (statusCode >> 8)[ptr[Char]] & 0xFF; // status code, high byte + payload(1) = statusCode & 0xFF; // status code, low byte + + if (reasonLen > 0) { + Memory.copy(payload~ptr~cast[ptr[Char]] + 2, reasonMessage, reasonLen~cast[ArchInt]); + } + + this.status = WsStatus.CLOSING; + + if Http.writeToWebSocket(this.connection, 8, payload~ptr, payloadLen) <= { + this.status = WsStatus.CLOSED; + } + + return result; + } + + // Called internally by the library to respond to a client-initiated + // close frame, completing the close handshake as stated in + // RFC 6455 §5.5.1 (a responder typically echoes the status code + // it received). + handler this.replyToClose (data : CharsPtr, dataLen : ArchWord) { + if this.status != WsStatus.OPENED return; + + // Extract and store the close code/reason before replying, + // per RFC 6455 §5.5.1: first 2 bytes = status code, rest = reason. + + if (dataLen~cast[Int] >= 2) { + this.closeCode = ((data~cnt(0)~cast[word[16]]) << 8) | data~cnt(1)~cast[word[16]]; + if (dataLen~cast[Int] > 2) { + def reasonLen: Int = (dataLen~cast[Int] - 2); + this.closeReason = String(data + 2, reasonLen); + } + } else { + // client sent a close frame with no code at all — valid per spec + this.closeCode = 1005; // "No Status Received" + this.closeReason = ""; + } + + this.status = WsStatus.CLOSING; + + if Http.writeToWebSocket(this.connection, 8, data, dataLen) <= 0 { + this.status = WsStatus.CLOSED; + } + + return result; + } + + handler this.onConnect(): Int as_ptr { + return 0; + } + + handler this.onReady() as_ptr { + } + + handler this.onData(bits: Int, data: CharsPtr, dataLen: ArchWord): Int { + def opcode : Int = bits & 0x0F; + def fin: Bool = (bits & 0x80) != 0; + + // Safe to cast: CivetWeb rejects any frame exceeding ~2 GiB (0x7FFF0000), + // which is within ArchInt's range, so this cast can't overflow or go negative. + + def dataLen : ArchInt = dataLen~cast[ArchInt]; + + if opcode == 1 or opcode == 2 { + if (this.isFragmenting) { + this.close(1002, "unexpected new message mid-fragment"); + return 0; + } + + this.fragmentOpcode = opcode; + this.fragmentBuffer.clear(); + + if (dataLen > this.getMaxMessageSize()) { + this.close(1009, "Message too big"); + return 0; + } + + this.fragmentBuffer.append(data, dataLen); + + if (fin) { + def isBinary : Bool = this.fragmentOpcode == 2; + this.onUserData(this.fragmentBuffer.string, isBinary); + this.fragmentBuffer.clear(); + } else { + this.isFragmenting = 1; + } + } + + if opcode == 0 { + if (!this.isFragmenting) { + // Protocol violation: CONTINUATION with no preceding TEXT/BINARY + this.close(1002, "unexpected continuation frame"); + return 0; + } + + if (this.fragmentBuffer.getLength() + dataLen > this.getMaxMessageSize()) { + this.close(1009, "Message too big"); + return 0; + } + + this.fragmentBuffer.append(data, dataLen); + + if (fin) { + def isBinary : Bool = this.fragmentOpcode == 2; + this.onUserData(this.fragmentBuffer.string, isBinary); + this.fragmentBuffer.clear(); + this.isFragmenting = 0; + } + } + + if opcode == 8 { + // reply with a close frame before tearing down — completes the handshake properly + if dataLen~cast[Int] >= 2 { + this.replyToClose(data, dataLen); // echo back client's code+reason + } else { + this.close(1000, ""); // client sent no code, reply with normal closure + } + return 0; + } + + if (opcode != 0 and opcode != 1 and opcode != 2 and opcode != 8) { + this.close(1002, "unsupported opcode"); + return 0; + } + + return 1; + } + + handler this.onUserData(data: ref[String], isBinary: Bool) as_ptr { + } + + handler this.onClose() as_ptr { + } + } +} diff --git a/WebPlatform/server.alusus b/WebPlatform/server.alusus index 016762d..85307ea 100644 --- a/WebPlatform/server.alusus +++ b/WebPlatform/server.alusus @@ -63,6 +63,7 @@ for i = 0, i < elements.getLength(), ++i { def uriParams: Array[String] = getModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي"); if uriParams.getLength() < 1 { + // TODO: Raise build notice instead System.fail(1, "Invalid @uiEndpoint params"); } @@ -217,6 +218,7 @@ for i = 0, i < elements.getLength(), ++i { def uriParams: Array[String] = getModifierParams(elements(i), "uiEndpoint", "منفذ_مرئي"); if uriParams.getLength() < 1 { + // TODO: Raise build notice instead System.fail(1, "Invalid @uiEndpoint params"); } def fnName: String = Spp.astMgr.getDefinitionName(elements(i)); @@ -231,18 +233,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]] = findFunctionElements(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 { + def endpointParams: Array[String] = getModifierParams(elements(i), "beEndpoint", "منفذ_بياني"); + if endpointParams.getLength() < 2 { + // TODO: Raise build notice instead System.fail(1, "Invalid BE endpoint params"); } Spp.astMgr.insertAst( @@ -276,6 +272,67 @@ } } + 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) + or wsEndpointParam.getLength() != 1 { + // TODO: Raise build notice instead. + System.fail(1, "Invalid WS endpoint params"); + } + Spp.astMgr.insertAst( + ast { + if String.isEqual(method, "GET") && String.isEqual(uri, "{{wsEndpointUri}}") { + return 0; + } + }, + AstTemplateMap() + .set(Srl.String("wsEndpointUri"), Core.Basic.TiStr(wsEndpointParam(0))) + ); + } + } + + function generateWebSocketRegistrations (modulesRef: ref[Core.Basic.TiObject]) { + def webSocketClasses: Array[ref[Core.Basic.TiObject]] = + findTypeElements(modulesRef, "wsEndpoint", "منفذ_مقبس"); + + def i : Int; + for i = 0, i < webSocketClasses.getLength(), i++ { + def params : Array[String] = getModifierParams(webSocketClasses(i) , "wsEndpoint", "منفذ_مقبس"); + + if (params.getLength() != 1) { + System.fail(1 , "where is the endpoint"); + } + + Spp.astMgr.insertAst( + ast { + Http.setWebSocketHandler( + httpContext, + "{{wsEndpointUri}}", + wsConnectCallback[webSocketClass]~ptr, + wsReadyCallback[webSocketClass]~ptr, + wsDataCallback[webSocketClass]~ptr, + wsCloseCallback[webSocketClass]~ptr, + null + ) + }, + AstTemplateMap() + .set(Srl.String("wsEndpointUri"), Core.Basic.TiStr(params(0))) + .set(Srl.String("webSocketClass"), constructElementFullReference(webSocketClasses(i))) + ); + } + } + // Querying Functions func getAssetsRoutesFromModules (modulesRef: ref[Core.Basic.TiObject]): Array[StaticRoute] { @@ -326,6 +383,30 @@ 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); @@ -351,6 +432,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 generateWebSocketRegistrations(modules(i)); + }; + return session; } @@ -414,7 +504,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") { @@ -463,6 +556,45 @@ return 1; } + func wsConnectCallback [WsConnClass: type] (connection: ptr[Http.Connection], userData: ptr[Void]): Int { + def ws: SrdRef[WsConnClass]; + ws.alloc()~init(connection); + ws.wkThis = ws; + + // we increment counter by one so we don't lose the object even if the user + // does not reference it + ws.refCounter.count++; + Http.setUserConnectionData(connection, ws.refCounter~ptr; + + return ws.onConnect(); + } + + func wsReadyCallback [WsConnClass: type] (connection: ptr[Http.Connection], userData: ptr[Void]): Void { + def rc: ptr[RefCounter] = Http.getUserConnectionData(connection)~cast[ptr[RefCounter]]; + def ws: SrdRef[WsConnClass](rc~cnt, castRef[rc~cnt.managedObj, WsConnClass]); + ws.status = WsStatus.OPENED; + ws.onReady(); + } + + func wsDataCallback [WsConnClass: type] ( + connection: ptr[Http.Connection], bits: Int, data: CharsPtr, dataLen: ArchWord, userData: ptr[Void] + ): Int { + def rc: ptr[RefCounter] = Http.getUserConnectionData(connection)~cast[ptr[RefCounter]]; + def ws: SrdRef[WsConnClass](rc~cnt, castRef[rc~cnt.managedObj, WsConnClass]); + return ws.onData(bits, data, dataLen); + } + + func wsCloseCallback [WsConnClass: type] (connection: ptr[Http.Connection], userData: ptr[Void]): Void { + def rc: ptr[RefCounter] = Http.getUserConnectionData(connection)~cast[ptr[RefCounter]]; + def ws: SrdRef[WsConnClass](rc~cnt, castRef[rc~cnt.managedObj, WsConnClass]); + ws.status = WsStatus.CLOSED; + ws.onClose(); + + // We no longer need this connection object, so decrease the reference counter which was manually + // incremented before being set on the Http.Connection instance. + ws.refCounter.count--; + } + func return404(connection: ptr[Http.Connection], uri: CharsPtr) { def content: array[Char, 1024]; String.assign(content~ptr, "
you are in \"%.512s\"", uri); @@ -474,31 +606,60 @@ // 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; + } + func constructElementFullReference (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); - if ownerRef.isNull() return identifier - else return Core.Basic.newSrdObj[Core.Data.Ast.LinkOperator].{ - Core.Basic.MapContainerOf[this].{ - setElement("first", ownerRef); - setElement("second", identifier); - }; + return Core.Basic.newSrdObj[Core.Data.Ast.Passage].{ + Core.Basic.BindingOf[this].setMember("target", element); }; } 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 translations: Map[String, String]; + translations.set(String(arKwd), String(enKwd)); + def modifier: ref[Core.Basic.TiObject](Spp.astMgr.findModifierForElement(elementRef, enKwd, translations)); def endpointParams: Array[String]; Spp.astMgr.getModifierStringParams(modifier, endpointParams); return endpointParams; } + function findFunctionElements ( + 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 findTypeElements ( + 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 + ); + } + 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] {