From 81f4d10ad6df7506bd66d676b850b193adee6fa7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 9 Feb 2026 11:48:04 +0530 Subject: [PATCH 1/5] Enhance Realtime channel handling for project queries and improve test coverage --- app/init/resources.php | 4 + app/realtime.php | 3 +- src/Appwrite/Messaging/Adapter/Realtime.php | 12 +- tests/e2e/Services/Realtime/RealtimeBase.php | 21 +++ .../RealtimeCustomClientQueryTest.php | 152 ++++++++++++++++++ 5 files changed, 190 insertions(+), 2 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 8f78df1573..55b7644488 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -520,6 +520,10 @@ Http::setResource('project', function ($dbForPlatform, $request, $console, $auth /** @var Utopia\Database\Document $console */ $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); + // Realtime channel "project" can send project=Query array + if (!\is_string($projectId)) { + $projectId = $request->getHeader('x-appwrite-project', ''); + } if (empty($projectId) || $projectId === 'console') { return $console; diff --git a/app/realtime.php b/app/realtime.php index 97e7465683..6b698c356f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -717,7 +717,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) { try { $response = new Response(new SwooleResponse()); $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -840,6 +840,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } } catch (Throwable $th) { + $logError($th, "realtimeMessage"); $code = $th->getCode(); if (!is_int($code)) { $code = 500; diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 51aad2a635..7ff19a1d63 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -344,8 +344,18 @@ class Realtime extends MessagingAdapter { $subscriptions = []; + // Param names that must not be treated as subscription queries for same-named channels if string: + $reservedParamNames = ['project']; + foreach ($channelNames as $channel) { - $params = $getQueryParam(\str_replace('.', '_', $channel)); + $paramKey = \str_replace('.', '_', $channel); + $params = $getQueryParam($paramKey); + + if (\in_array($paramKey, $reservedParamNames, true)) { + if (\is_string($paramKey)) { + $params = null; + } + } if ($params === null) { if (!isset($subscriptions[0])) { diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 1b77c0ad4a..92d29ba3a3 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -68,6 +68,27 @@ trait RealtimeBase ); } + /** + * Build WebSocket client with custom query parameters. + * Useful for testing edge cases like project in header only, or project as Query array. + * + * @param array $queryParams Custom query parameters (e.g., ['channels' => ['project'], 'project' => [...]]) + * @param array $headers HTTP headers + * @return WebSocketClient + */ + private function getWebsocketWithCustomQuery(array $queryParams, array $headers = []): WebSocketClient + { + $queryString = http_build_query($queryParams); + + return new WebSocketClient( + "ws://appwrite.test/v1/realtime?" . $queryString, + [ + "headers" => $headers, + "timeout" => 30, + ] + ); + } + public function testConnection(): void { /** diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 37ea1e2e05..030b917989 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2262,4 +2262,156 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } + + public function testProjectChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test OLD SDK behavior: project=projectId (string) in query param + // The reserved param logic should treat string project param as project ID, not as subscription queries + $clientOldSdk = $this->getWebsocket(['project'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], $projectId, null); + + $response = json_decode($clientOldSdk->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since project param was treated as project ID, not queries + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $clientOldSdk->close(); + + // Test NEW SDK behavior: project=Query array in query param, project ID in header + // The reserved param logic should use Query array as subscription queries for project channel + $queryArray = [Query::select(['*'])->toString()]; + $clientNewSdk = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientNewSdk->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + // Should have subscription with the provided query + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $clientNewSdk->close(); + + // Test edge case: project param is array but not a valid Query array (should still connect, use header) + $clientEdgeCase = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => ['invalid', 'array'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientEdgeCase->receive(), true); + // Should connect successfully using header for project ID + $this->assertEquals('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + + $clientEdgeCase->close(); + } + + public function testProjectChannelWithHeaderOnly() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test: project ID only in header, no project query param + // This simulates a client that only uses x-appwrite-project header + $client = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since no project query param + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $client->close(); + + // Test: project channel with queries, project ID only in header + $queryArray = [Query::select(['*'])->toString()]; + $clientWithQuery = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientWithQuery->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $clientWithQuery->close(); + + // Test: channels channel (reserved param) should not parse channels list as queries + $clientChannelsChannel = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['channels'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientChannelsChannel->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains('channels', $response['data']['channels']); + // Should have default select(['*']) since channels param is reserved + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $clientChannelsChannel->close(); + } } From 842c274d02e0af2bbdfde88e9e4839bfcdc47398 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 9 Feb 2026 11:55:39 +0530 Subject: [PATCH 2/5] updated tests --- .../RealtimeCustomClientQueryTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 030b917989..468bd2592f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2414,4 +2414,63 @@ class RealtimeCustomClientQueryTest extends Scope $clientChannelsChannel->close(); } + + public function testTestsChannelWithQueries() + { + $projectId = 'console'; + + // Subscribe without queries - should receive all events + $clientNoQuery = $this->getWebsocket(['tests'], [ + 'origin' => 'http://localhost', + ], $projectId); + + $response = json_decode($clientNoQuery->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Subscribe with matching query - should receive events + $clientWithMatchingQuery = $this->getWebsocket(['tests'], [ + 'origin' => 'http://localhost', + ], $projectId, [ + Query::equal('response', ['WS:/v1/realtime:passed'])->toString(), + ]); + + $response = json_decode($clientWithMatchingQuery->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Subscribe with non-matching query - should NOT receive events + $clientWithNonMatchingQuery = $this->getWebsocket(['tests'], [ + 'origin' => 'http://localhost', + ], $projectId, [ + Query::equal('response', ['failed'])->toString(), + ]); + + $response = json_decode($clientWithNonMatchingQuery->receive(), true); + $this->assertEquals('connected', $response['type']); + + sleep(6); + + // Client without query should receive event + $eventNoQuery = json_decode($clientNoQuery->receive(), true); + $this->assertEquals('event', $eventNoQuery['type']); + $this->assertEquals('test.event', $eventNoQuery['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventNoQuery['data']['payload']['response']); + + // Client with matching query should receive event + $eventMatching = json_decode($clientWithMatchingQuery->receive(), true); + $this->assertEquals('event', $eventMatching['type']); + $this->assertEquals('test.event', $eventMatching['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventMatching['data']['payload']['response']); + + // Client with non-matching query should NOT receive event + try { + $clientWithNonMatchingQuery->receive(); + $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientNoQuery->close(); + $clientWithMatchingQuery->close(); + $clientWithNonMatchingQuery->close(); + } } From ecea1a580d5b49a47ab03c611e723eede1eee027 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 9 Feb 2026 12:02:01 +0530 Subject: [PATCH 3/5] updated channels --- .../RealtimeCustomClientQueryTest.php | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 468bd2592f..d3277f6778 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2391,28 +2391,6 @@ class RealtimeCustomClientQueryTest extends Scope $this->assertNotEmpty($response['data']['subscriptions']); $clientWithQuery->close(); - - // Test: channels channel (reserved param) should not parse channels list as queries - $clientChannelsChannel = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['channels'] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientChannelsChannel->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('channels', $response['data']['channels']); - // Should have default select(['*']) since channels param is reserved - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientChannelsChannel->close(); } public function testTestsChannelWithQueries() From 4b3c4323eeb30bad83f70478b48ee33764915064 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 9 Feb 2026 13:07:11 +0530 Subject: [PATCH 4/5] Enhance reserved parameter handling in Realtime adapter and update related tests --- app/realtime.php | 4 +-- src/Appwrite/Messaging/Adapter/Realtime.php | 27 ++++++++++++++----- .../RealtimeCustomClientQueryTest.php | 13 +++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 6b698c356f..0c5d041dc9 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -690,11 +690,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $code = 500; } - $message = $th->getMessage(); // sanitize 0 && 5xx errors - if (($code === 0 || $code >= 500) && !Http::isDevelopment()) { + $realtimeViolation = $th instanceof AppwriteException && $th->getType() === AppwriteException::REALTIME_POLICY_VIOLATION; + if (($code === 0 || $code >= 500) && !$realtimeViolation && !Http::isDevelopment()) { $message = 'Error: Server Error'; } diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7ff19a1d63..8f6add2201 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -344,15 +344,28 @@ class Realtime extends MessagingAdapter { $subscriptions = []; - // Param names that must not be treated as subscription queries for same-named channels if string: - $reservedParamNames = ['project']; + /** + * Reserved channel params with expected type + * If matched the expected type then skip the query parsing like in project + */ + $reservedParamExpectedTypes = [ + 'project' => 'string', + ]; foreach ($channelNames as $channel) { $paramKey = \str_replace('.', '_', $channel); $params = $getQueryParam($paramKey); - if (\in_array($paramKey, $reservedParamNames, true)) { - if (\is_string($paramKey)) { + if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { + $expectedType = $reservedParamExpectedTypes[$paramKey]; + $isRoutingType = match ($expectedType) { + 'array' => \is_array($params), + 'string' => \is_string($params), + default => false, + }; + + // If the value matches the expected routing type, do NOT use it as queries + if ($isRoutingType) { $params = null; } } @@ -368,7 +381,7 @@ class Realtime extends MessagingAdapter continue; } - if (!is_array($params)) { + if (!\is_array($params)) { $params = [$params]; } @@ -377,12 +390,12 @@ class Realtime extends MessagingAdapter $subscriptions[$index] = ['channels' => [], 'queries' => []]; } - if (!in_array($channel, $subscriptions[$index]['channels'])) { + if (!\in_array($channel, $subscriptions[$index]['channels'], true)) { $subscriptions[$index]['channels'][] = $channel; } if (empty($subscriptions[$index]['queries'])) { - $raw = is_array($slot) ? $slot : [$slot]; + $raw = \is_array($slot) ? $slot : [$slot]; $subscriptions[$index]['queries'] = self::convertQueries($raw); } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index d3277f6778..b1fbb14bbb 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2270,7 +2270,8 @@ class RealtimeCustomClientQueryTest extends Scope $projectId = $this->getProject()['$id']; // Test OLD SDK behavior: project=projectId (string) in query param - // The reserved param logic should treat string project param as project ID, not as subscription queries + // For reserved \"project\" param, string is treated as routing-only (project ID), + // and is not used as queries for the project channel. We should fall back to select(*). $clientOldSdk = $this->getWebsocket(['project'], [ 'origin' => 'http://localhost', 'cookie' => 'a_session_' . $projectId . '=' . $session, @@ -2315,7 +2316,8 @@ class RealtimeCustomClientQueryTest extends Scope $clientNewSdk->close(); - // Test edge case: project param is array but not a valid Query array (should still connect, use header) + // Test edge case: project param is array but not a valid Query array + // This should now fail with an invalid query error rather than silently falling back. $clientEdgeCase = $this->getWebsocketWithCustomQuery( [ 'channels' => ['project'], @@ -2329,11 +2331,8 @@ class RealtimeCustomClientQueryTest extends Scope ); $response = json_decode($clientEdgeCase->receive(), true); - // Should connect successfully using header for project ID - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - - $clientEdgeCase->close(); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('Invalid query', $response['data']['message']); } public function testProjectChannelWithHeaderOnly() From 6afb5ccf10e07b6e2b7afcd673dfa9f476f3679a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 9 Feb 2026 13:08:02 +0530 Subject: [PATCH 5/5] updated the comments --- src/Appwrite/Messaging/Adapter/Realtime.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 8f6add2201..e61cfdc116 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -358,14 +358,14 @@ class Realtime extends MessagingAdapter if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { $expectedType = $reservedParamExpectedTypes[$paramKey]; - $isRoutingType = match ($expectedType) { + $isExpectedType = match ($expectedType) { 'array' => \is_array($params), 'string' => \is_string($params), default => false, }; - // If the value matches the expected routing type, do NOT use it as queries - if ($isRoutingType) { + // If the value matches the expected type dont use it the queries + if ($isExpectedType) { $params = null; } }