Skip to content

Commit 629e536

Browse files
authored
Merge pull request #23 from utopia-php/feat-client
feat: add Client class
2 parents 5ac26c1 + c07abec commit 629e536

13 files changed

Lines changed: 621 additions & 142 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@ jobs:
4646
sleep 10
4747
4848
- name: Run Tests
49-
run: docker compose exec tests vendor/bin/phpunit
49+
run: docker compose exec tests vendor/bin/phpunit --debug

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
"swoole/ide-helper": "5.1.2",
2525
"textalk/websocket": "1.5.2",
2626
"phpunit/phpunit": "^9.5.5",
27-
"workerman/workerman": "^4.0",
28-
"phpstan/phpstan": "^1.8",
27+
"workerman/workerman": "4.1.*",
28+
"phpstan/phpstan": "^1.12",
2929
"laravel/pint": "^1.15"
3030
}
3131
}

composer.lock

Lines changed: 81 additions & 84 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

php-8.0.Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM composer:2.0 as composer
1+
FROM composer:2.0 AS composer
22

33
ARG TESTING=false
44
ENV TESTING=$TESTING
@@ -15,7 +15,7 @@ RUN composer install \
1515
--no-scripts \
1616
--prefer-dist
1717

18-
FROM appwrite/utopia-base:php-8.0-0.1.0 as final
18+
FROM appwrite/utopia-base:php-8.0-0.1.0 AS final
1919

2020
RUN docker-php-ext-install sockets pcntl
2121

php-8.1.Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM composer:2.0 as composer
1+
FROM composer:2.0 AS composer
22

33
ARG TESTING=false
44
ENV TESTING=$TESTING
@@ -15,7 +15,7 @@ RUN composer install \
1515
--no-scripts \
1616
--prefer-dist
1717

18-
FROM appwrite/utopia-base:php-8.1-0.1.0 as final
18+
FROM appwrite/utopia-base:php-8.1-0.1.0 AS final
1919

2020
RUN docker-php-ext-install sockets pcntl
2121

php-8.2.Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM composer:2.0 as composer
1+
FROM composer:2.0 AS composer
22

33
ARG TESTING=false
44
ENV TESTING=$TESTING
@@ -15,7 +15,7 @@ RUN composer install \
1515
--no-scripts \
1616
--prefer-dist
1717

18-
FROM appwrite/utopia-base:php-8.2-0.1.0 as final
18+
FROM appwrite/utopia-base:php-8.2-0.1.0 AS final
1919

2020
RUN docker-php-ext-install sockets pcntl
2121

php-8.3.Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM composer:2.0 as composer
1+
FROM composer:2.0 AS composer
22

33
ARG TESTING=false
44
ENV TESTING=$TESTING
@@ -15,7 +15,7 @@ RUN composer install \
1515
--no-scripts \
1616
--prefer-dist
1717

18-
FROM appwrite/utopia-base:php-8.3-0.1.0 as final
18+
FROM appwrite/utopia-base:php-8.3-0.1.0 AS final
1919

2020
RUN docker-php-ext-install sockets pcntl
2121

phpunit.xml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
convertErrorsToExceptions="true"
66
convertNoticesToExceptions="true"
77
convertWarningsToExceptions="true"
8-
processIsolation="false"
9-
stopOnFailure="false"
8+
processIsolation="true"
9+
stopOnFailure="true"
1010
>
1111
<testsuites>
12-
<!-- <testsuite name="Unit">
12+
<testsuite name="Unit">
1313
<directory>./tests/unit/</directory>
14-
</testsuite> -->
14+
</testsuite>
1515
<testsuite name="E2E">
1616
<directory>./tests/e2e/</directory>
1717
</testsuite>

src/WebSocket/Client.php

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<?php
2+
3+
namespace Utopia\WebSocket;
4+
5+
use Swoole\Coroutine\Http\Client as SwooleClient;
6+
use Swoole\WebSocket\Frame;
7+
8+
class Client
9+
{
10+
private SwooleClient $client;
11+
private bool $connected = false;
12+
private string $host;
13+
private int $port;
14+
private string $path;
15+
/** @var array<string, string> */
16+
private array $headers;
17+
private float $timeout;
18+
19+
// Event handlers
20+
private ?\Closure $onMessage = null;
21+
private ?\Closure $onClose = null;
22+
private ?\Closure $onError = null;
23+
private ?\Closure $onOpen = null;
24+
private ?\Closure $onPing = null;
25+
private ?\Closure $onPong = null;
26+
27+
/**
28+
* @param string $url
29+
* @param array{headers?: array<string, string>, timeout?: float} $options
30+
*/
31+
public function __construct(string $url, array $options = [])
32+
{
33+
$parsedUrl = parse_url($url);
34+
if ($parsedUrl === false) {
35+
throw new \InvalidArgumentException('Invalid WebSocket URL');
36+
}
37+
38+
if (!isset($parsedUrl['host'])) {
39+
throw new \InvalidArgumentException('WebSocket URL must contain a host');
40+
}
41+
42+
$this->host = $parsedUrl['host'];
43+
$this->port = $parsedUrl['port'] ?? (isset($parsedUrl['scheme']) && $parsedUrl['scheme'] === 'wss' ? 443 : 80);
44+
$this->path = $parsedUrl['path'] ?? '/';
45+
if (isset($parsedUrl['query'])) {
46+
$this->path .= '?' . $parsedUrl['query'];
47+
}
48+
49+
$this->headers = $options['headers'] ?? [];
50+
$this->timeout = $options['timeout'] ?? 30;
51+
}
52+
53+
public function connect(): void
54+
{
55+
$this->client = new SwooleClient($this->host, $this->port, $this->port === 443);
56+
$this->client->set([
57+
'timeout' => $this->timeout,
58+
'websocket_compression' => true,
59+
'max_frame_size' => 32 * 1024 * 1024, // 32MB max frame size
60+
]);
61+
62+
if (!empty($this->headers)) {
63+
$this->client->setHeaders($this->headers);
64+
}
65+
66+
$success = $this->client->upgrade($this->path);
67+
68+
if (!$success) {
69+
$error = new \RuntimeException(
70+
"WebSocket connection failed: {$this->client->errCode} - {$this->client->errMsg}"
71+
);
72+
$this->emit('error', $error);
73+
throw $error;
74+
}
75+
76+
$this->connected = true;
77+
$this->emit('open');
78+
}
79+
80+
public function listen(): void
81+
{
82+
while ($this->connected) {
83+
try {
84+
$frame = $this->client->recv($this->timeout);
85+
86+
if ($frame === false) {
87+
if ($this->client->errCode === SWOOLE_ERROR_CLIENT_NO_CONNECTION) {
88+
$this->handleClose();
89+
break;
90+
}
91+
throw new \RuntimeException(
92+
"Failed to receive data: {$this->client->errCode} - {$this->client->errMsg}"
93+
);
94+
}
95+
96+
if ($frame === "") {
97+
continue;
98+
}
99+
100+
if ($frame instanceof Frame) {
101+
$this->handleFrame($frame);
102+
}
103+
} catch (\Throwable $e) {
104+
$this->emit('error', $e);
105+
$this->handleClose();
106+
break;
107+
}
108+
}
109+
}
110+
111+
private function handleFrame(Frame $frame): void
112+
{
113+
switch ($frame->opcode) {
114+
case WEBSOCKET_OPCODE_TEXT:
115+
$this->emit('message', $frame->data);
116+
break;
117+
case WEBSOCKET_OPCODE_CLOSE:
118+
$this->handleClose();
119+
break;
120+
case WEBSOCKET_OPCODE_PING:
121+
$this->emit('ping', $frame->data);
122+
$this->client->push('', WEBSOCKET_OPCODE_PONG);
123+
break;
124+
case WEBSOCKET_OPCODE_PONG:
125+
$this->emit('pong', $frame->data);
126+
break;
127+
}
128+
}
129+
130+
private function handleClose(): void
131+
{
132+
if ($this->connected) {
133+
$this->connected = false;
134+
$this->emit('close');
135+
$this->client->close();
136+
}
137+
}
138+
139+
public function send(string $data): void
140+
{
141+
if (!$this->connected) {
142+
throw new \RuntimeException('Not connected to WebSocket server');
143+
}
144+
145+
$success = $this->client->push($data);
146+
147+
if ($success === false) {
148+
$error = new \RuntimeException(
149+
"Failed to send data: {$this->client->errCode} - {$this->client->errMsg}"
150+
);
151+
$this->emit('error', $error);
152+
throw $error;
153+
}
154+
}
155+
156+
public function close(): void
157+
{
158+
$this->handleClose();
159+
}
160+
161+
public function isConnected(): bool
162+
{
163+
return $this->connected;
164+
}
165+
166+
// Event handling methods
167+
public function onMessage(\Closure $callback): self
168+
{
169+
$this->onMessage = $callback;
170+
return $this;
171+
}
172+
173+
public function onClose(\Closure $callback): self
174+
{
175+
$this->onClose = $callback;
176+
return $this;
177+
}
178+
179+
public function onError(\Closure $callback): self
180+
{
181+
$this->onError = $callback;
182+
return $this;
183+
}
184+
185+
public function onOpen(\Closure $callback): self
186+
{
187+
$this->onOpen = $callback;
188+
return $this;
189+
}
190+
191+
public function onPing(\Closure $callback): self
192+
{
193+
$this->onPing = $callback;
194+
return $this;
195+
}
196+
197+
public function onPong(\Closure $callback): self
198+
{
199+
$this->onPong = $callback;
200+
return $this;
201+
}
202+
203+
/**
204+
* @param string $event
205+
* @param mixed $data
206+
*/
207+
private function emit(string $event, mixed $data = null): void
208+
{
209+
$handler = match ($event) {
210+
'message' => $this->onMessage,
211+
'close' => $this->onClose,
212+
'error' => $this->onError,
213+
'open' => $this->onOpen,
214+
'ping' => $this->onPing,
215+
'pong' => $this->onPong,
216+
default => null
217+
};
218+
219+
if ($handler !== null) {
220+
$handler($data);
221+
}
222+
}
223+
224+
public function receive(): ?string
225+
{
226+
if (!$this->connected) {
227+
throw new \RuntimeException('Not connected to WebSocket server');
228+
}
229+
230+
$frame = $this->client->recv($this->timeout);
231+
232+
if ($frame === false) {
233+
if ($this->client->errCode === SWOOLE_ERROR_CLIENT_NO_CONNECTION) {
234+
$this->handleClose();
235+
return null;
236+
}
237+
throw new \RuntimeException(
238+
"Failed to receive data: {$this->client->errCode} - {$this->client->errMsg}"
239+
);
240+
}
241+
242+
if ($frame === "") {
243+
return null;
244+
}
245+
246+
if ($frame instanceof Frame) {
247+
switch ($frame->opcode) {
248+
case WEBSOCKET_OPCODE_TEXT:
249+
return $frame->data;
250+
case WEBSOCKET_OPCODE_CLOSE:
251+
$this->handleClose();
252+
return null;
253+
case WEBSOCKET_OPCODE_PING:
254+
$this->emit('ping', $frame->data);
255+
$this->client->push('', WEBSOCKET_OPCODE_PONG);
256+
return null;
257+
case WEBSOCKET_OPCODE_PONG:
258+
$this->emit('pong', $frame->data);
259+
return null;
260+
}
261+
}
262+
263+
return null;
264+
}
265+
}

0 commit comments

Comments
 (0)