diff --git a/CHANGELOG.md b/CHANGELOG.md index 49df56d..2debb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to ext-websocket are documented here. +## 0.10.0 - 2026-05-18 + +### Added + +- Added `WebSocket\ServerOptions` for typed server configuration. + +### Changed + +- Updated `WebSocket\Server` to accept `ServerOptions` while keeping array options supported. +- Updated examples and production documentation to prefer `ServerOptions`. + ## 0.9.1 - 2026-05-18 ### Added diff --git a/README.md b/README.md index 98e5a7f..4945353 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Native WebSocket extension for PHP. `ext-websocket` keeps RFC 6455 protocol work in C and exposes a small PHP API for synchronous PHP code, async runtimes, and the native server runtime included in the extension. -Current version: `0.9.1`. +Current version: `0.10.0`. ## Requirements @@ -104,11 +104,13 @@ Options: | `maxMessageSize` | Maximum incoming text/binary message size; defaults to 16 MiB | | `maxQueuedBytes` | Maximum outgoing queued bytes per connection; defaults to 16 MiB | +Prefer `WebSocket\ServerOptions` for explicit configuration. Associative arrays remain supported for compatibility. + Methods: | Method | Description | |---|---| -| `__construct(array $options = [])` | Create a server | +| `__construct(ServerOptions\|array $options = [])` | Create a server | | `listen(string $host, int $port): void` | Bind address for `run()` | | `onOpen(Closure $handler): void` | Register upgraded connection callback | | `onMessage(Closure $handler): void` | Register text/binary message callback | diff --git a/docs/production.md b/docs/production.md index 060bfe5..bcd9423 100644 --- a/docs/production.md +++ b/docs/production.md @@ -30,10 +30,10 @@ Recommended process managers: Set explicit limits for your workload: ```php -$server = new WebSocket\Server([ - 'maxMessageSize' => 1024 * 1024, - 'maxQueuedBytes' => 8 * 1024 * 1024, -]); +$server = new WebSocket\Server(new WebSocket\ServerOptions( + maxMessageSize: 1024 * 1024, + maxQueuedBytes: 8 * 1024 * 1024, +)); ``` `maxMessageSize` protects incoming frames and fragmented messages. `maxQueuedBytes` protects memory when a client reads slowly and outgoing writes need to be queued. diff --git a/examples/run_server.php b/examples/run_server.php index ca2d756..45b6d07 100644 --- a/examples/run_server.php +++ b/examples/run_server.php @@ -12,6 +12,7 @@ use WebSocket\MessageType; use WebSocket\Protocol; use WebSocket\Server; +use WebSocket\ServerOptions; if (!extension_loaded('websocket')) { fwrite(STDERR, "The websocket extension is not loaded.\n"); @@ -27,9 +28,9 @@ exit(1); } -$server = new Server([ - 'maxMessageSize' => 1024 * 1024, -]); +$server = new Server(new ServerOptions( + maxMessageSize: 1024 * 1024, +)); $server->listen($host, $port); diff --git a/php_websocket.h b/php_websocket.h index 267d740..cd2f43e 100644 --- a/php_websocket.h +++ b/php_websocket.h @@ -19,7 +19,7 @@ extern zend_module_entry websocket_module_entry; #define phpext_websocket_ptr &websocket_module_entry -#define PHP_WEBSOCKET_VERSION "0.9.1" +#define PHP_WEBSOCKET_VERSION "0.10.0" #define WEBSOCKET_HTTP_MAX_REQUEST_SIZE 8192 #define WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE (16 * 1024 * 1024) #define WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES (16 * 1024 * 1024) @@ -132,6 +132,7 @@ typedef struct _websocket_connection_object { } websocket_connection_object; extern zend_class_entry *websocket_server_ce; +extern zend_class_entry *websocket_server_options_ce; extern zend_class_entry *websocket_connection_ce; extern zend_class_entry *websocket_message_type_ce; extern zend_class_entry *websocket_frame_ce; diff --git a/tests/001-contracts.phpt b/tests/001-contracts.phpt index 21aea82..904c5c2 100644 --- a/tests/001-contracts.phpt +++ b/tests/001-contracts.phpt @@ -5,6 +5,7 @@ websocket --FILE-- getNumberOfParameters()); +var_dump((new ReflectionMethod(WebSocket\ServerOptions::class, '__construct'))->getNumberOfParameters()); +$options = new WebSocket\ServerOptions(maxMessageSize: 1024, maxQueuedBytes: 2048); +var_dump($options->maxMessageSize); +var_dump($options->maxQueuedBytes); +try { + new WebSocket\ServerOptions(maxMessageSize: 0); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} var_dump((new ReflectionMethod(WebSocket\Frame::class, '__construct'))->getNumberOfParameters()); var_dump((new ReflectionMethod(WebSocket\CloseFrame::class, '__construct'))->getNumberOfParameters()); @@ -31,9 +41,14 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) bool(false) bool(false) int(2) +int(2) +int(1024) +int(2048) +WebSocket\ServerOptions::__construct(): Argument #1 ($maxMessageSize) must be at least 1 int(3) int(2) bool(true) diff --git a/tests/011-server-fragmentation-size.phpt b/tests/011-server-fragmentation-size.phpt index ecce28f..4d2b1c2 100644 --- a/tests/011-server-fragmentation-size.phpt +++ b/tests/011-server-fragmentation-size.phpt @@ -42,7 +42,7 @@ $serverCode = <<<'PHP' use WebSocket\Connection; use WebSocket\Server; -$server = new Server(['maxMessageSize' => 5]); +$server = new Server(new WebSocket\ServerOptions(maxMessageSize: 5)); $server->listen('127.0.0.1', PORT_PLACEHOLDER); $server->onOpen(static function (Connection $connection): void { diff --git a/websocket.c b/websocket.c index 35fbfcf..c3237fa 100644 --- a/websocket.c +++ b/websocket.c @@ -11,6 +11,7 @@ ZEND_DECLARE_MODULE_GLOBALS(websocket) zend_class_entry *websocket_server_ce; +zend_class_entry *websocket_server_options_ce; zend_class_entry *websocket_connection_ce; zend_class_entry *websocket_message_type_ce; zend_class_entry *websocket_frame_ce; diff --git a/websocket.stub.php b/websocket.stub.php index 0d76299..4bedb2e 100644 --- a/websocket.stub.php +++ b/websocket.stub.php @@ -23,9 +23,9 @@ final class Server * - maxMessageSize: maximum accepted text/binary message size in bytes. * - maxQueuedBytes: maximum queued outgoing bytes per connection. * - * @param array{maxMessageSize?: positive-int, maxQueuedBytes?: positive-int} $options + * @param ServerOptions|array{maxMessageSize?: positive-int, maxQueuedBytes?: positive-int} $options */ - public function __construct(array $options = []) {} + public function __construct(ServerOptions|array $options = []) {} /** * Bind the TCP listener used by run(). @@ -86,6 +86,34 @@ public function stop(): void {} public function getDriver(): string {} } +/** + * Explicit configuration for WebSocket\Server. + */ +final class ServerOptions +{ + /** + * Maximum accepted text/binary message size in bytes. + * + * @var positive-int + */ + public readonly int $maxMessageSize; + + /** + * Maximum queued outgoing bytes per connection. + * + * @var positive-int + */ + public readonly int $maxQueuedBytes; + + /** + * @param positive-int $maxMessageSize + * @param positive-int $maxQueuedBytes + * + * @throws \ValueError If a limit is less than 1. + */ + public function __construct(int $maxMessageSize = 16 * 1024 * 1024, int $maxQueuedBytes = 16 * 1024 * 1024) {} +} + /** * Runtime connection accepted by WebSocket\Server. */ diff --git a/websocket_arginfo.h b/websocket_arginfo.h index 15aed64..555e2db 100644 --- a/websocket_arginfo.h +++ b/websocket_arginfo.h @@ -9,7 +9,12 @@ #include "Zend/zend_enum.h" ZEND_BEGIN_ARG_INFO_EX(arginfo_class_WebSocket_Server___construct, 0, 0, 0) - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 0, "[]") + { "options", ZEND_TYPE_INIT_CLASS_CONST_MASK("WebSocket\\ServerOptions", MAY_BE_ARRAY | _ZEND_ARG_INFO_FLAGS(0, 0, 0)), "[]" }, +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_WebSocket_ServerOptions___construct, 0, 0, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, maxMessageSize, IS_LONG, 0, "16 * 1024 * 1024") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, maxQueuedBytes, IS_LONG, 0, "16 * 1024 * 1024") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_WebSocket_Server_listen, 0, 2, IS_VOID, 0) @@ -88,6 +93,7 @@ ZEND_METHOD(WebSocket_Server, onError); ZEND_METHOD(WebSocket_Server, run); ZEND_METHOD(WebSocket_Server, stop); ZEND_METHOD(WebSocket_Server, getDriver); +ZEND_METHOD(WebSocket_ServerOptions, __construct); ZEND_METHOD(WebSocket_Connection, send); ZEND_METHOD(WebSocket_Connection, close); ZEND_METHOD(WebSocket_Connection, isOpen); @@ -112,6 +118,11 @@ static const zend_function_entry class_WebSocket_Server_methods[] = { ZEND_FE_END }; +static const zend_function_entry class_WebSocket_ServerOptions_methods[] = { + ZEND_ME(WebSocket_ServerOptions, __construct, arginfo_class_WebSocket_ServerOptions___construct, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static const zend_function_entry class_WebSocket_Connection_methods[] = { ZEND_ME(WebSocket_Connection, send, arginfo_class_WebSocket_Connection_send, ZEND_ACC_PUBLIC) ZEND_ME(WebSocket_Connection, close, arginfo_class_WebSocket_Connection_close, ZEND_ACC_PUBLIC) @@ -148,6 +159,24 @@ static zend_class_entry *register_class_WebSocket_Server(void) return class_entry; } +static zend_class_entry *register_class_WebSocket_ServerOptions(void) +{ + zend_class_entry ce, *class_entry; + zval property_maxMessageSize_default_value; + zval property_maxQueuedBytes_default_value; + + INIT_NS_CLASS_ENTRY(ce, "WebSocket", "ServerOptions", class_WebSocket_ServerOptions_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + + ZVAL_UNDEF(&property_maxMessageSize_default_value); + zend_declare_typed_property(class_entry, zend_string_init("maxMessageSize", sizeof("maxMessageSize") - 1, 1), &property_maxMessageSize_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + + ZVAL_UNDEF(&property_maxQueuedBytes_default_value); + zend_declare_typed_property(class_entry, zend_string_init("maxQueuedBytes", sizeof("maxQueuedBytes") - 1, 1), &property_maxQueuedBytes_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + + return class_entry; +} + static zend_class_entry *register_class_WebSocket_Connection(void) { zend_class_entry ce, *class_entry; diff --git a/websocket_server.c b/websocket_server.c index 120f4a3..210bf24 100644 --- a/websocket_server.c +++ b/websocket_server.c @@ -83,16 +83,44 @@ PHP_METHOD(WebSocket_Server, __construct) ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL - Z_PARAM_ARRAY(options) + Z_PARAM_ZVAL(options) ZEND_PARSE_PARAMETERS_END(); if (options) { + if (Z_TYPE_P(options) != IS_ARRAY && (Z_TYPE_P(options) != IS_OBJECT || !instanceof_function(Z_OBJCE_P(options), websocket_server_options_ce))) { + zend_argument_type_error(1, "must be of type array|WebSocket\\ServerOptions, %s given", websocket_zval_value_name(options)); + RETURN_THROWS(); + } ZVAL_COPY(&intern->options, options); } else { array_init(&intern->options); } } +PHP_METHOD(WebSocket_ServerOptions, __construct) +{ + zend_long max_message_size = WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE; + zend_long max_queued_bytes = WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES; + + ZEND_PARSE_PARAMETERS_START(0, 2) + Z_PARAM_OPTIONAL + Z_PARAM_LONG(max_message_size) + Z_PARAM_LONG(max_queued_bytes) + ZEND_PARSE_PARAMETERS_END(); + + if (max_message_size < 1) { + zend_argument_value_error(1, "must be at least 1"); + RETURN_THROWS(); + } + if (max_queued_bytes < 1) { + zend_argument_value_error(2, "must be at least 1"); + RETURN_THROWS(); + } + + zend_update_property_long(websocket_server_options_ce, Z_OBJ_P(ZEND_THIS), "maxMessageSize", sizeof("maxMessageSize") - 1, max_message_size); + zend_update_property_long(websocket_server_options_ce, Z_OBJ_P(ZEND_THIS), "maxQueuedBytes", sizeof("maxQueuedBytes") - 1, max_queued_bytes); +} + PHP_METHOD(WebSocket_Server, listen) { zend_string *host; @@ -234,6 +262,7 @@ PHP_METHOD(WebSocket_Server, getDriver) void websocket_register_server_class(void) { websocket_server_ce = register_class_WebSocket_Server(); + websocket_server_options_ce = register_class_WebSocket_ServerOptions(); websocket_server_ce->create_object = websocket_server_create_object; memcpy(&websocket_server_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers)); diff --git a/websocket_server_runtime.c b/websocket_server_runtime.c index 26f3244..c1b9a93 100644 --- a/websocket_server_runtime.c +++ b/websocket_server_runtime.c @@ -370,13 +370,16 @@ static void websocket_connection_discard_read_bytes(websocket_connection_object static size_t websocket_server_max_message_size(websocket_server_object *intern) { zval *value; + zval rv; - if (Z_TYPE(intern->options) != IS_ARRAY) { - return WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE; - } - - value = zend_hash_str_find(Z_ARRVAL(intern->options), "maxMessageSize", sizeof("maxMessageSize") - 1); - if (!value) { + if (Z_TYPE(intern->options) == IS_ARRAY) { + value = zend_hash_str_find(Z_ARRVAL(intern->options), "maxMessageSize", sizeof("maxMessageSize") - 1); + if (!value) { + return WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE; + } + } else if (Z_TYPE(intern->options) == IS_OBJECT && instanceof_function(Z_OBJCE(intern->options), websocket_server_options_ce)) { + value = zend_read_property(websocket_server_options_ce, Z_OBJ(intern->options), "maxMessageSize", sizeof("maxMessageSize") - 1, 0, &rv); + } else { return WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE; } @@ -391,13 +394,16 @@ static size_t websocket_server_max_message_size(websocket_server_object *intern) static size_t websocket_server_max_queued_bytes(websocket_server_object *intern) { zval *value; + zval rv; - if (Z_TYPE(intern->options) != IS_ARRAY) { - return WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES; - } - - value = zend_hash_str_find(Z_ARRVAL(intern->options), "maxQueuedBytes", sizeof("maxQueuedBytes") - 1); - if (!value) { + if (Z_TYPE(intern->options) == IS_ARRAY) { + value = zend_hash_str_find(Z_ARRVAL(intern->options), "maxQueuedBytes", sizeof("maxQueuedBytes") - 1); + if (!value) { + return WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES; + } + } else if (Z_TYPE(intern->options) == IS_OBJECT && instanceof_function(Z_OBJCE(intern->options), websocket_server_options_ce)) { + value = zend_read_property(websocket_server_options_ce, Z_OBJ(intern->options), "maxQueuedBytes", sizeof("maxQueuedBytes") - 1, 0, &rv); + } else { return WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES; }