Enhance WebSocket support: add opcode handling, binary message support, and improve connection validation
This commit is contained in:
parent
86dc93864e
commit
46d98a092f
@ -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:
|
||||
|
||||
@ -2,4 +2,5 @@ Task API
|
||||
|
||||
kill
|
||||
task
|
||||
task_repeat
|
||||
task_pid
|
||||
|
||||
13
doc/areas/websocket.txt
Normal file
13
doc/areas/websocket.txt
Normal 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
20
doc/pages/1_WS.txt
Normal 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
|
||||
@ -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
17
doc/pages/task_repeat.txt
Normal 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
|
||||
17
doc/pages/ws_broadcast.txt
Normal file
17
doc/pages/ws_broadcast.txt
Normal 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
14
doc/pages/ws_close.txt
Normal 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
|
||||
14
doc/pages/ws_connection_count.txt
Normal file
14
doc/pages/ws_connection_count.txt
Normal 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
|
||||
13
doc/pages/ws_connection_id.txt
Normal file
13
doc/pages/ws_connection_id.txt
Normal 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
|
||||
14
doc/pages/ws_connections.txt
Normal file
14
doc/pages/ws_connections.txt
Normal 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
|
||||
13
doc/pages/ws_is_binary.txt
Normal file
13
doc/pages/ws_is_binary.txt
Normal 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
15
doc/pages/ws_message.txt
Normal 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
16
doc/pages/ws_opcode.txt
Normal 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
15
doc/pages/ws_scope.txt
Normal 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
17
doc/pages/ws_send.txt
Normal 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
15
doc/pages/ws_send_to.txt
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = "");
|
||||
|
||||
@ -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 = "");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
154
src/lib/uri.cpp
154
src/lib/uri.cpp
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user