Enhance WebSocket support: add opcode handling, binary message support, and improve connection validation

This commit is contained in:
udo 2026-04-18 18:41:50 +00:00
parent 86dc93864e
commit 46d98a092f
25 changed files with 623 additions and 69 deletions

View File

@ -20,6 +20,8 @@ The runtime keeps the socket lifecycle in-process and exposes a low-boilerplate
- `ws_message()`
- `ws_connection_id()`
- `ws_scope()`
- `ws_opcode()`
- `ws_is_binary()`
- `ws_connections([scope])`
- `ws_connection_count([scope])`
- `ws_send(message[, scope])`
@ -29,6 +31,12 @@ The runtime keeps the socket lifecycle in-process and exposes a low-boilerplate
By default, the WebSocket scope is the current page file, so `ws_send()` broadcasts to other clients connected to that same `.ws.uce` endpoint.
`ws_message()` may contain either text or binary payload data. Use `ws_opcode()` / `ws_is_binary()` to inspect the current inbound message type.
The current `ws_send*` helpers queue text frames. Binary send helpers are not exposed yet.
The runtime now accepts fragmented messages, validates reserved bits and UTF-8 for text payloads, and delivers both text and binary message frames into `WS()`.
## Service Setup
The repository now includes a systemd unit template and a management script for the FastCGI runtime:

View File

@ -2,4 +2,5 @@ Task API
kill
task
task_repeat
task_pid

13
doc/areas/websocket.txt Normal file
View File

@ -0,0 +1,13 @@
WebSocket Functions
ws_broadcast
ws_close
ws_connection_count
ws_connection_id
ws_connections
ws_is_binary
ws_message
ws_opcode
ws_scope
ws_send
ws_send_to

20
doc/pages/1_WS.txt Normal file
View File

@ -0,0 +1,20 @@
:sig
WS()
:desc
Defines the WebSocket message handler for the current `.ws.uce` page.
The same page may expose both `RENDER()` and `WS()`. `RENDER()` serves the initial HTTP response, while `WS()` is called whenever a complete WebSocket message arrives for that page.
UCE reassembles fragmented messages before calling `WS()`. Text and binary frames are both delivered. Use `ws_opcode()` and `ws_is_binary()` to distinguish them.
The `call` parameter passed into `WS()` contains:
- `message`
- `connection_id`
- `scope`
- `opcode`
- `document_uri`
:see
>websocket

View File

@ -1,8 +1,9 @@
:sig
String session_start(String session_name)
String session_start(String session_name = "uce-session")
:params
return value : the session ID, defaults to "uce-session"
session_name : optional name of the session cookie, defaults to "uce-session"
return value : the current session ID
:desc
Starts session or connects to existing session. This function sets a cookie with the name contained in 'session_name' if it does not exist and fills that cookie with a new unique session ID. It then loads the session data for that session ID. Afterwards, the following fields are populated in the 'context' variable:

17
doc/pages/task_repeat.txt Normal file
View File

@ -0,0 +1,17 @@
:sig
pid_t task_repeat(String key, f64 interval, std::function<void()> exec_func, u64 timeout = 60*10)
:params
key : string uniquely identifying the task
interval : repeat interval in seconds
exec_func : function to execute repeatedly
timeout : optional task timeout value
return value : the process ID of the started (or still running) task
:desc
Starts a repeating background worker process. The function `exec_func` is executed in a loop and the worker sleeps for `interval` seconds between executions.
If a process with the same `key` is already running, `task_repeat()` does not start a second worker and instead returns the PID of the existing one.
:see
>task

View File

@ -0,0 +1,17 @@
:sig
u64 ws_broadcast(String message, String scope = "")
:params
message : text message to send
scope : optional scope identifier, defaults to the current WebSocket page scope
return value : number of clients the message was queued for
:desc
Queues a text WebSocket message for every client connected to the given scope and returns the number of recipients.
If `scope` is omitted, the current page scope is used.
This helper currently sends text frames only.
:see
>websocket

14
doc/pages/ws_close.txt Normal file
View File

@ -0,0 +1,14 @@
:sig
bool ws_close(String connection_id = "")
:params
connection_id : optional target client ID, defaults to the current WebSocket client
return value : true if the target connection exists and was scheduled to close
:desc
Queues a WebSocket close frame and closes the targeted connection.
If `connection_id` is omitted, the current connection handled by `WS()` is closed.
:see
>websocket

View File

@ -0,0 +1,14 @@
:sig
u64 ws_connection_count(String scope = "")
:params
scope : optional scope identifier, defaults to the current WebSocket page scope
return value : number of connected WebSocket clients in that scope
:desc
Returns the number of currently connected WebSocket clients for the given scope.
If `scope` is omitted, the current page scope is used.
:see
>websocket

View File

@ -0,0 +1,13 @@
:sig
String ws_connection_id()
:params
return value : connection ID of the current WebSocket client
:desc
Returns the runtime-generated connection ID of the client whose message is currently being handled.
This ID can be passed to `ws_send_to()` or `ws_close()` to target a single connected client.
:see
>websocket

View File

@ -0,0 +1,14 @@
:sig
StringList ws_connections(String scope = "")
:params
scope : optional scope identifier, defaults to the current WebSocket page scope
return value : list of connection IDs currently connected to that scope
:desc
Returns the currently connected WebSocket client IDs for the given scope.
If `scope` is omitted, the current page scope is used.
:see
>websocket

View File

@ -0,0 +1,13 @@
:sig
bool ws_is_binary()
:params
return value : true if the current WebSocket message is binary
:desc
Returns whether the message currently being handled by `WS()` arrived as a binary frame.
If this returns `false`, the current message was delivered as a text frame.
:see
>websocket

15
doc/pages/ws_message.txt Normal file
View File

@ -0,0 +1,15 @@
:sig
String ws_message()
:params
return value : payload of the current WebSocket message
:desc
Returns the payload of the current WebSocket message being handled by `WS()`.
For text frames this is the decoded text payload. For binary frames this String contains the raw message bytes.
Use `ws_is_binary()` or `ws_opcode()` to decide how the payload should be interpreted.
:see
>websocket

16
doc/pages/ws_opcode.txt Normal file
View File

@ -0,0 +1,16 @@
:sig
u8 ws_opcode()
:params
return value : opcode of the current WebSocket message
:desc
Returns the opcode of the message currently being handled by `WS()`.
Common values are:
- `0x1` for text messages
- `0x2` for binary messages
:see
>websocket

15
doc/pages/ws_scope.txt Normal file
View File

@ -0,0 +1,15 @@
:sig
String ws_scope()
:params
return value : scope identifier of the current WebSocket endpoint
:desc
Returns the runtime's scope identifier for the current WebSocket endpoint.
This is the same default scope used by `ws_send()`, `ws_broadcast()`, `ws_connections()`, and `ws_connection_count()` when no explicit scope is supplied.
In the current runtime implementation this scope is the page's internal endpoint identifier, typically the absolute `SCRIPT_FILENAME` of the `.ws.uce` file.
:see
>websocket

17
doc/pages/ws_send.txt Normal file
View File

@ -0,0 +1,17 @@
:sig
bool ws_send(String message, String scope = "")
:params
message : text message to send
scope : optional scope identifier, defaults to the current WebSocket page scope
return value : true if the message was queued for at least one connected client
:desc
Queues a text WebSocket message for every client connected to the given scope.
If `scope` is omitted, the current page scope is used.
This helper currently sends text frames only.
:see
>websocket

15
doc/pages/ws_send_to.txt Normal file
View File

@ -0,0 +1,15 @@
:sig
bool ws_send_to(String connection_id, String message)
:params
connection_id : ID of the target WebSocket client
message : text message to send
return value : true if the target connection exists and the message was queued
:desc
Queues a text WebSocket message for one specific connected client.
This helper currently sends text frames only.
:see
>websocket

View File

@ -60,6 +60,20 @@ make_http_text_response(String status_line, String body, String extra_headers =
);
}
static bool
is_valid_close_code(u16 status_code)
{
if(status_code < 1000)
return(false);
if(status_code == 1004 || status_code == 1005 || status_code == 1006 || status_code == 1015)
return(false);
if(status_code <= 1014)
return(true);
if(status_code >= 3000 && status_code <= 4999)
return(true);
return(false);
}
void
FastCGIServer::shutdown()
{
@ -217,6 +231,36 @@ FastCGIServer::close_http_listeners()
}
}
void
FastCGIServer::close_websocket_connection(Connection& connection, u16 status_code, String reason)
{
if(!connection.close_socket)
connection.output_buffer += ws_close_frame(status_code, reason);
connection.close_socket = true;
}
void
FastCGIServer::fail_websocket_connection(Connection& connection, u16 status_code, String reason)
{
close_websocket_connection(connection, status_code, reason);
}
void
FastCGIServer::dispatch_websocket_message(Connection& connection, RequestID request_id, String payload, u8 opcode)
{
RequestList::iterator it = connection.requests.find(request_id);
if(it == connection.requests.end() || !on_websocket_message)
return;
it->second->resources.is_websocket = true;
it->second->resources.websocket_connection_id = connection.websocket_connection_id;
it->second->resources.websocket_scope = connection.websocket_scope;
it->second->resources.websocket_opcode = opcode;
it->second->resources.websocket_is_binary = (opcode == 0x2);
it->second->resources.websocket_is_text = (opcode == 0x1);
on_websocket_message(*it->second, payload, opcode);
}
void
FastCGIServer::process(int timeout_ms)
{
@ -394,9 +438,39 @@ FastCGIServer::process_http_request(FastCGIRequest& request, String& data)
if(to_lower(request.params["HTTP_UPGRADE"]) == "websocket")
{
Connection* connection = client_sockets[request.resources.client_socket];
String request_method = trim(request.params["REQUEST_METHOD"]);
String request_protocol = trim(request.params["SERVER_PROTOCOL"]);
String websocket_connection = to_lower(request.params["HTTP_CONNECTION"]);
String websocket_host = trim(request.params["HTTP_HOST"]);
String websocket_key = trim(request.params["HTTP_SEC_WEBSOCKET_KEY"]);
String websocket_version = trim(request.params["HTTP_SEC_WEBSOCKET_VERSION"]);
if(request_method != "GET")
{
connection->output_buffer += make_http_text_response(
"HTTP/1.1 405 Method Not Allowed",
"websocket upgrades require GET"
);
connection->close_socket = true;
return;
}
if(request_protocol != "HTTP/1.1")
{
connection->output_buffer += make_http_text_response(
"HTTP/1.1 400 Bad Request",
"websocket upgrades require HTTP/1.1"
);
connection->close_socket = true;
return;
}
if(websocket_host == "")
{
connection->output_buffer += make_http_text_response(
"HTTP/1.1 400 Bad Request",
"missing Host header"
);
connection->close_socket = true;
return;
}
if(websocket_connection.find("upgrade") == String::npos)
{
connection->output_buffer += make_http_text_response(
@ -415,6 +489,24 @@ FastCGIServer::process_http_request(FastCGIRequest& request, String& data)
connection->close_socket = true;
return;
}
if(!ws_is_valid_client_key(websocket_key))
{
connection->output_buffer += make_http_text_response(
"HTTP/1.1 400 Bad Request",
"invalid Sec-WebSocket-Key header"
);
connection->close_socket = true;
return;
}
if(websocket_version == "")
{
connection->output_buffer += make_http_text_response(
"HTTP/1.1 400 Bad Request",
"missing Sec-WebSocket-Version header"
);
connection->close_socket = true;
return;
}
if(websocket_version != "" && websocket_version != "13")
{
connection->output_buffer += make_http_text_response(
@ -482,44 +574,108 @@ FastCGIServer::process_websocket_input(Connection& connection)
if(!frame.mask_bit)
{
connection.output_buffer += ws_close_frame(1002, "client frames must be masked");
connection.close_socket = true;
fail_websocket_connection(connection, 1002, "client frames must be masked");
return;
}
if(!frame.is_final_fragment || frame.opcode == 0x0)
bool is_control_frame = (frame.opcode & 0x08) != 0;
if(is_control_frame && !frame.is_final_fragment)
{
connection.output_buffer += ws_close_frame(1003, "fragmented frames are not supported");
connection.close_socket = true;
fail_websocket_connection(connection, 1002, "control frames must not be fragmented");
return;
}
switch(frame.opcode)
{
case 0x1:
case 0x0:
{
RequestList::iterator it = connection.requests.find(connection.client_socket);
if(it != connection.requests.end() && on_websocket_message)
if(connection.websocket_fragment_opcode == 0)
{
it->second->resources.is_websocket = true;
it->second->resources.websocket_connection_id = connection.websocket_connection_id;
it->second->resources.websocket_scope = connection.websocket_scope;
on_websocket_message(*it->second, frame.payload);
fail_websocket_connection(connection, 1002, "unexpected continuation frame");
return;
}
connection.websocket_fragment_buffer += frame.payload;
if(!frame.is_final_fragment)
break;
String payload = connection.websocket_fragment_buffer;
u8 opcode = connection.websocket_fragment_opcode;
connection.websocket_fragment_buffer = "";
connection.websocket_fragment_opcode = 0;
if(opcode == 0x1 && !ws_is_valid_utf8(payload))
{
fail_websocket_connection(connection, 1007, "invalid UTF-8 text message");
return;
}
dispatch_websocket_message(connection, connection.client_socket, payload, opcode);
break;
}
case 0x1:
case 0x2:
{
if(connection.websocket_fragment_opcode != 0)
{
fail_websocket_connection(connection, 1002, "new data frame while fragmented message is active");
return;
}
if(frame.is_final_fragment)
{
if(frame.opcode == 0x1 && !ws_is_valid_utf8(frame.payload))
{
fail_websocket_connection(connection, 1007, "invalid UTF-8 text message");
return;
}
dispatch_websocket_message(connection, connection.client_socket, frame.payload, frame.opcode);
break;
}
connection.websocket_fragment_buffer = frame.payload;
connection.websocket_fragment_opcode = frame.opcode;
break;
}
case 0x8:
connection.output_buffer += ws_close_frame();
connection.close_socket = true;
{
if(frame.payload.length() == 1)
{
fail_websocket_connection(connection, 1002, "invalid close frame payload");
return;
}
u16 status_code = 1000;
String reason = "";
if(frame.payload.length() >= 2)
{
status_code =
((u16)(u8)frame.payload[0] << 8) |
(u16)(u8)frame.payload[1];
reason = frame.payload.substr(2);
if(!is_valid_close_code(status_code))
{
fail_websocket_connection(connection, 1002, "invalid close status code");
return;
}
if(!ws_is_valid_utf8(reason))
{
fail_websocket_connection(connection, 1007, "invalid UTF-8 close reason");
return;
}
}
close_websocket_connection(connection, status_code, reason);
return;
}
case 0x9:
connection.output_buffer += ws_encode_frame(frame.payload, 0xA);
break;
case 0xA:
break;
default:
connection.output_buffer += ws_close_frame(1003, "unsupported websocket opcode");
connection.close_socket = true;
fail_websocket_connection(connection, 1002, "unsupported websocket opcode");
return;
}
}

View File

@ -48,7 +48,7 @@ public:
std::function<int(FastCGIRequest&)> on_request = 0;
std::function<int(FastCGIRequest&)> on_data = 0;
std::function<int(FastCGIRequest&)> on_complete = 0;
std::function<int(FastCGIRequest&, const String&)> on_websocket_message = 0;
std::function<int(FastCGIRequest&, const String&, u8)> on_websocket_message = 0;
int listen(unsigned tcp_port);
int listen_http(unsigned tcp_port);
@ -72,6 +72,8 @@ public:
bool is_websocket = false;
String websocket_connection_id;
String websocket_scope;
String websocket_fragment_buffer;
u8 websocket_fragment_opcode = 0;
char type = 'F'; // F = FastCGI, H = HttpServer
};
@ -86,6 +88,9 @@ public:
void close_http_listeners();
void read_fgci(Connection&);
void process_websocket_input(Connection&);
void fail_websocket_connection(Connection& connection, u16 status_code, String reason = "");
void close_websocket_connection(Connection& connection, u16 status_code = 1000, String reason = "");
void dispatch_websocket_message(Connection& connection, RequestID request_id, String payload, u8 opcode);
bool websocket_send_to(String connection_id, String message);
u64 websocket_broadcast(String scope, String message);
StringList websocket_connection_ids(String scope = "");

View File

@ -39,6 +39,8 @@ String socket_read(u64 sockfd, u32 max_length = 1024*128, u32 timeout = 1);
String ws_message();
String ws_connection_id();
String ws_scope();
u8 ws_opcode();
bool ws_is_binary();
StringList ws_connections(String scope = "");
u64 ws_connection_count(String scope = "");
bool ws_send(String message, String scope = "");

View File

@ -173,6 +173,9 @@ struct Request {
bool is_websocket = false;
String websocket_connection_id = "";
String websocket_scope = "";
u8 websocket_opcode = 0;
bool websocket_is_binary = false;
bool websocket_is_text = false;
std::string params_buffer;
} resources;

View File

@ -27,6 +27,134 @@ static String base64_encode(String raw)
return(result);
}
static int base64_decode_value(char c)
{
if(c >= 'A' && c <= 'Z')
return(c - 'A');
if(c >= 'a' && c <= 'z')
return(c - 'a' + 26);
if(c >= '0' && c <= '9')
return(c - '0' + 52);
if(c == '+')
return(62);
if(c == '/')
return(63);
return(-1);
}
static String base64_decode(String raw, bool& ok)
{
ok = false;
String cleaned;
for(char c : raw)
{
if(!isspace(c))
cleaned.append(1, c);
}
if(cleaned.length() == 0 || (cleaned.length() % 4) != 0)
return("");
String result;
for(u32 i = 0; i < cleaned.length(); i += 4)
{
int values[4];
int padding = 0;
for(u32 j = 0; j < 4; j++)
{
char c = cleaned[i + j];
if(c == '=')
{
values[j] = 0;
padding += 1;
}
else
{
values[j] = base64_decode_value(c);
if(values[j] < 0)
return("");
}
}
if(padding > 2)
return("");
if(padding > 0 && i + 4 != cleaned.length())
return("");
if(cleaned[i + 2] == '=' && cleaned[i + 3] != '=')
return("");
result.append(1, (char)((values[0] << 2) | (values[1] >> 4)));
if(cleaned[i + 2] != '=')
result.append(1, (char)(((values[1] & 0x0F) << 4) | (values[2] >> 2)));
if(cleaned[i + 3] != '=')
result.append(1, (char)(((values[2] & 0x03) << 6) | values[3]));
}
ok = true;
return(result);
}
bool ws_is_valid_utf8(String input)
{
u32 i = 0;
while(i < input.length())
{
u8 c = (u8)input[i];
u32 trailing = 0;
u32 codepoint = 0;
if(c <= 0x7F)
{
i += 1;
continue;
}
else if((c & 0xE0) == 0xC0)
{
trailing = 1;
codepoint = c & 0x1F;
if(codepoint == 0)
return(false);
}
else if((c & 0xF0) == 0xE0)
{
trailing = 2;
codepoint = c & 0x0F;
}
else if((c & 0xF8) == 0xF0)
{
trailing = 3;
codepoint = c & 0x07;
}
else
{
return(false);
}
if(i + trailing >= input.length())
return(false);
for(u32 j = 1; j <= trailing; j++)
{
u8 follow = (u8)input[i + j];
if((follow & 0xC0) != 0x80)
return(false);
codepoint = (codepoint << 6) | (follow & 0x3F);
}
if((trailing == 1 && codepoint < 0x80) ||
(trailing == 2 && codepoint < 0x800) ||
(trailing == 3 && codepoint < 0x10000))
return(false);
if(codepoint > 0x10FFFF)
return(false);
if(codepoint >= 0xD800 && codepoint <= 0xDFFF)
return(false);
i += trailing + 1;
}
return(true);
}
String var_dump(URI uri, String prefix, String postfix)
{
return(
@ -463,6 +591,13 @@ String ws_make_accept_key(String client_key)
)));
}
bool ws_is_valid_client_key(String client_key)
{
bool ok = false;
String decoded = base64_decode(trim(client_key), ok);
return(ok && decoded.length() == 16);
}
String ws_encode_frame(String payload, u8 opcode, bool is_final_fragment)
{
String frame;
@ -513,6 +648,9 @@ bool WSFrame::parse(const String& buffer, String& error)
const unsigned char* raw = (const unsigned char*)buffer.data();
opcode = raw[0] & 0x0F;
is_final_fragment = (raw[0] & 0x80) != 0;
rsv1 = (raw[0] & 0x40) != 0;
rsv2 = (raw[0] & 0x20) != 0;
rsv3 = (raw[0] & 0x10) != 0;
mask_bit = (raw[1] & 0x80) != 0;
payload_length = raw[1] & 0x7F;
header_length = 2;
@ -528,6 +666,11 @@ bool WSFrame::parse(const String& buffer, String& error)
{
if(buffer.length() < 10)
return(false);
if((raw[2] & 0x80) != 0)
{
error = "invalid websocket frame length";
return(false);
}
payload_length = 0;
for(u32 i = 0; i < 8; i++)
payload_length = (payload_length << 8) | (u64)raw[2 + i];
@ -544,6 +687,17 @@ bool WSFrame::parse(const String& buffer, String& error)
error = "invalid websocket frame length";
return(false);
}
if(rsv1 || rsv2 || rsv3)
{
error = "reserved websocket bits are not supported";
return(false);
}
bool is_control_frame = (opcode & 0x08) != 0;
if(is_control_frame && payload_length > 125)
{
error = "control frames must be 125 bytes or less";
return(false);
}
if(buffer.length() < frame_length)
return(false);

View File

@ -18,14 +18,19 @@ void save_session_data(String session_id, StringMap data);
String session_start(String session_name = "uce-session");
void session_destroy(String session_name = "uce-session");
String ws_make_accept_key(String client_key);
bool ws_is_valid_client_key(String client_key);
String ws_encode_frame(String payload, u8 opcode = 0x1, bool is_final_fragment = true);
String ws_close_frame(u16 status_code = 1000, String reason = "");
bool ws_is_valid_utf8(String input);
struct WSFrame {
u8 opcode = 0;
bool is_final_fragment = false;
bool mask_bit = false;
bool rsv1 = false;
bool rsv2 = false;
bool rsv3 = false;
u64 payload_length = 0;
u64 header_length = 0;
u64 frame_length = 0;

View File

@ -58,6 +58,20 @@ String ws_scope()
return(current_ws_scope());
}
u8 ws_opcode()
{
if(!context)
return(0);
return(context->resources.websocket_opcode);
}
bool ws_is_binary()
{
if(!context)
return(false);
return(context->resources.websocket_is_binary);
}
StringList ws_connections(String scope)
{
return(server.websocket_connection_ids(normalize_ws_scope(scope)));
@ -173,7 +187,7 @@ int handle_complete(FastCGIRequest& request) {
return 0;
}
int handle_websocket_message(FastCGIRequest& request, const String& message)
int handle_websocket_message(FastCGIRequest& request, const String& message, u8 opcode)
{
Request event_request;
ByteStream ws_output;
@ -202,6 +216,9 @@ int handle_websocket_message(FastCGIRequest& request, const String& message)
event_request.var["ws"]["connection_id"] = request.resources.websocket_connection_id;
event_request.var["ws"]["scope"] = request.resources.websocket_scope;
event_request.var["ws"]["connection_count"] = (f64)server.websocket_connection_ids(request.resources.websocket_scope).size();
event_request.var["ws"]["opcode"] = (f64)opcode;
event_request.var["ws"]["is_binary"].set_bool(request.resources.websocket_is_binary);
event_request.var["ws"]["is_text"].set_bool(request.resources.websocket_is_text);
event_request.var["ws"]["document_uri"] = first(
request.params["DOCUMENT_URI"],
request.params["REQUEST_URI"]
@ -210,6 +227,7 @@ int handle_websocket_message(FastCGIRequest& request, const String& message)
call_param["message"] = message;
call_param["connection_id"] = request.resources.websocket_connection_id;
call_param["scope"] = request.resources.websocket_scope;
call_param["opcode"] = (f64)opcode;
call_param["document_uri"] = event_request.var["ws"]["document_uri"].to_string();
compiler_invoke_websocket(&event_request, request.params["SCRIPT_FILENAME"], call_param);

View File

@ -26,46 +26,33 @@ RENDER()
u64 online = ws_connection_count();
<>
<link rel="stylesheet" href='style.css?v=<?= time() ?>'></link>
<style>
.chat-shell { max-width: 960px; margin: 1.5rem auto; display: grid; gap: 1rem; }
.chat-meta { display: flex; gap: 1rem; flex-wrap: wrap; color: #445; }
.chat-panel { background: #fff; border: 1px solid #d7dde7; border-radius: 14px; padding: 1rem; box-shadow: 0 10px 30px rgba(38,55,77,0.08); }
.chat-log { min-height: 24rem; max-height: 55vh; overflow: auto; display: grid; gap: 0.75rem; }
.chat-line { border-left: 4px solid #2f7ad8; padding-left: 0.75rem; }
.chat-line.system { border-left-color: #8794a7; color: #556; }
.chat-line .meta { font-size: 0.8rem; color: #667; margin-bottom: 0.2rem; }
.chat-composer { display: grid; gap: 0.75rem; }
.chat-row { display: grid; gap: 0.5rem; }
.chat-row.dual { grid-template-columns: minmax(0, 220px) minmax(0, 1fr); }
input, textarea, button { font: inherit; border-radius: 10px; border: 1px solid #c5cedb; padding: 0.8rem 0.9rem; }
textarea { min-height: 6rem; resize: vertical; }
button { background: #1b5fc9; border-color: #1b5fc9; color: #fff; cursor: pointer; font-weight: 600; }
button:disabled { opacity: 0.55; cursor: wait; }
.status { font-weight: 600; }
</style>
<div class="chat-shell">
<h1>
<a href="index.uce">UCE Test</a>:
WebSocket Chat
</h1>
<div class="chat-meta">
<div>Page scope: <code><?= ws_url ?></code></div>
<div>Connected right now: <strong id="online-count"><?= online ?></strong></div>
<div class="status" id="status">Connecting...</div>
<h1>
<a href="index.uce">UCE Test</a>:
WebSocket Chat
</h1>
<label>Chat Info</label>
<pre>Page scope: <?= ws_url ?>
Connected right now: <span id="online-count"><?= online ?></span>
Status: <span id="status">Connecting...</span></pre>
<form id="chat-form" action="?" method="post">
<div>
<label>Display name:</label>
<input id="chat-name" type="text" maxlength="32" value="guest-<?= draw_int(1000, 9999) ?>"/>
</div>
<div class="chat-panel">
<div class="chat-log" id="chat-log"></div>
<div>
<label>Message:</label>
<textarea id="chat-message" maxlength="500" placeholder="Say something to everyone connected to this same .ws.uce page"></textarea>
</div>
<form class="chat-panel chat-composer" id="chat-form">
<div class="chat-row dual">
<input id="chat-name" maxlength="32" placeholder="Display name" value="guest-<?= draw_int(1000, 9999) ?>"/>
<button id="send-button" type="submit">Send Message</button>
</div>
<div class="chat-row">
<textarea id="chat-message" maxlength="500" placeholder="Say something to everyone connected to this same .ws.uce page"></textarea>
</div>
</form>
</div>
<div>
<input id="send-button" type="submit" value="Send Message"/>
</div>
</form>
<label>Chat Log</label>
<pre id="chat-log"></pre>
<script>
const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}<?= ws_url ?>`;
const statusEl = document.getElementById('status');
@ -82,17 +69,9 @@ RENDER()
let reconnectDelay = 1000;
function addLine(kind, title, body, meta = '') {
const wrapper = document.createElement('div');
wrapper.className = `chat-line ${kind}`;
const metaEl = document.createElement('div');
metaEl.className = 'meta';
metaEl.textContent = meta;
const bodyEl = document.createElement('div');
const titleEl = document.createElement('strong');
titleEl.textContent = title;
bodyEl.append(titleEl, document.createTextNode(` ${body}`));
wrapper.append(metaEl, bodyEl);
logEl.append(wrapper);
const prefix = kind === 'system' ? '[system]' : `[${title}]`;
const metaText = meta ? ` ${meta}` : '';
logEl.append(`${prefix} ${body}${metaText}\n`);
logEl.scrollTop = logEl.scrollHeight;
}
@ -168,6 +147,15 @@ RENDER()
WS()
{
if(ws_is_binary())
{
ws_send_to(
ws_connection_id(),
json_encode(chat_event("notice", "Binary messages are not handled by this demo"))
);
return;
}
DTree payload = json_decode(ws_message());
String type = clean_chat_field(payload["type"].to_string(), 24);
String name = clean_chat_field(payload["name"].to_string(), 32);