create-prisma-php-app 5.1.0-alpha.3 → 5.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/dist/.github/copilot-instructions.md +80 -33
- package/dist/AGENTS.md +59 -25
- package/dist/bootstrap.php +207 -187
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/bs-config.ts +44 -1
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/src/Lib/Auth/Auth.php +12 -25
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
- package/dist/src/Lib/Websocket/Socket.php +170 -0
- package/dist/src/Lib/Websocket/SocketPool.php +50 -0
- package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
- package/dist/src/Lib/Websocket/sockets.php +50 -0
- package/dist/src/Lib/Websocket/websocket-server.php +10 -3
- package/dist/src/app/globals.css +3 -1
- package/dist/src/app/layout.php +1 -1
- package/dist/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/DeferComponentRootsTest.php +147 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +119 -0
- package/dist/tests/RpcWireContractTest.php +101 -0
- package/dist/tests/SocketPoolTest.php +69 -0
- package/dist/tests/SocketRegistryTest.php +77 -0
- package/dist/tests/SocketTest.php +124 -0
- package/dist/tests/SocketsRegistrationTest.php +40 -0
- package/dist/tests/Support/FakeConnection.php +62 -0
- package/dist/tests/Support/Features.php +38 -0
- package/dist/tests/Support/RequiresFeature.php +26 -0
- package/dist/tests/bootstrap.php +44 -0
- package/dist/ts/main.ts +5 -8
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- package/dist/public/js/pp-reactive-v2.js +0 -1
|
@@ -1,47 +1,500 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
declare(strict_types=1);
|
|
4
|
-
|
|
5
|
-
namespace Lib\Websocket;
|
|
6
|
-
|
|
7
|
-
use Ratchet\MessageComponentInterface;
|
|
8
|
-
use Ratchet\ConnectionInterface;
|
|
9
|
-
use
|
|
10
|
-
use
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Lib\Websocket;
|
|
6
|
+
|
|
7
|
+
use Ratchet\MessageComponentInterface;
|
|
8
|
+
use Ratchet\ConnectionInterface;
|
|
9
|
+
use React\EventLoop\LoopInterface;
|
|
10
|
+
use React\EventLoop\TimerInterface;
|
|
11
|
+
use Lib\Auth\Auth;
|
|
12
|
+
use PP\Env;
|
|
13
|
+
use Exception;
|
|
14
|
+
use SplObjectStorage;
|
|
15
|
+
use Throwable;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The server half of `pp.socket(...)`: the PulsePoint named-socket wire.
|
|
19
|
+
*
|
|
20
|
+
* Every socket connects to one endpoint (`/__pulsepoint/ws` through the dev
|
|
21
|
+
* proxy, this Ratchet server directly otherwise), naming its function in the
|
|
22
|
+
* `name` query parameter. The arguments do not travel in the URL — a URL is
|
|
23
|
+
* logged by every proxy on the way — but as the first text frame, one JSON
|
|
24
|
+
* object, exactly the payload `pp.rpc` would have posted. Every frame after
|
|
25
|
+
* that is one JSON value, in either direction.
|
|
26
|
+
*
|
|
27
|
+
* There is no status line inside an open connection, so failure is a frame:
|
|
28
|
+
* `{"error": "..."}` — that key alone — followed by a close. The client
|
|
29
|
+
* runtime routes it to `onError` rather than `onMessage`.
|
|
30
|
+
*
|
|
31
|
+
* Handshake security mirrors the reference server: an anti-CSWSH origin
|
|
32
|
+
* check and a connection ceiling before anything else, then per-socket auth
|
|
33
|
+
* (`requireAuth` / `allowedRoles` on the registry entry, verified against
|
|
34
|
+
* the JWT auth cookie), then the shared message-size, message-rate, and
|
|
35
|
+
* idle-timeout limits for the life of the connection.
|
|
36
|
+
*/
|
|
37
|
+
class ConnectionManager implements MessageComponentInterface
|
|
38
|
+
{
|
|
39
|
+
private const ARGS_TIMEOUT_SECONDS = 10;
|
|
40
|
+
|
|
41
|
+
/** @var SplObjectStorage<ConnectionInterface, object> */
|
|
42
|
+
protected SplObjectStorage $clients;
|
|
43
|
+
|
|
44
|
+
private ?LoopInterface $loop = null;
|
|
45
|
+
private ?TimerInterface $idleSweepTimer = null;
|
|
46
|
+
private int $openConnections = 0;
|
|
47
|
+
|
|
48
|
+
public function __construct()
|
|
49
|
+
{
|
|
50
|
+
$this->clients = new SplObjectStorage();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Give the manager the event loop so it can run the first-frame timeout
|
|
55
|
+
* and the idle sweep. Without a loop the wire still works; only the
|
|
56
|
+
* time-based limits are disabled.
|
|
57
|
+
*/
|
|
58
|
+
public function attachLoop(LoopInterface $loop): void
|
|
59
|
+
{
|
|
60
|
+
$this->loop = $loop;
|
|
61
|
+
|
|
62
|
+
$this->idleSweepTimer ??= $loop->addPeriodicTimer(15, function (): void {
|
|
63
|
+
$idleTimeout = self::idleTimeoutSeconds();
|
|
64
|
+
$now = microtime(true);
|
|
65
|
+
|
|
66
|
+
foreach ($this->clients as $conn) {
|
|
67
|
+
$state = $this->clients[$conn];
|
|
68
|
+
if ($now - $state->lastActivity >= $idleTimeout) {
|
|
69
|
+
$conn->close(1000);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public function openConnectionCount(): int
|
|
76
|
+
{
|
|
77
|
+
return $this->openConnections;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public function onOpen(ConnectionInterface $conn): void
|
|
81
|
+
{
|
|
82
|
+
if (!$this->isOriginAllowed($conn)) {
|
|
83
|
+
$conn->close(1008);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if ($this->openConnections >= self::maxConnections()) {
|
|
88
|
+
$conn->close(1013);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$entry = $this->resolveEntry($conn);
|
|
93
|
+
if (is_string($entry)) {
|
|
94
|
+
$this->refuse($conn, $entry);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$payload = $this->verifiedPayload($conn);
|
|
99
|
+
|
|
100
|
+
$refusal = $this->authorize($entry, $payload);
|
|
101
|
+
if ($refusal !== null) {
|
|
102
|
+
$this->refuse($conn, $refusal);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
$state = new \stdClass();
|
|
107
|
+
$state->entry = $entry;
|
|
108
|
+
$state->payload = $payload;
|
|
109
|
+
$state->socket = null; // Set once the argument frame arrives.
|
|
110
|
+
$state->lastActivity = microtime(true);
|
|
111
|
+
$state->messageTimestamps = [];
|
|
112
|
+
$state->argsTimer = $this->loop?->addTimer(
|
|
113
|
+
self::ARGS_TIMEOUT_SECONDS,
|
|
114
|
+
function () use ($conn): void {
|
|
115
|
+
$this->refuse(
|
|
116
|
+
$conn,
|
|
117
|
+
'This socket opened and sent no arguments. The first frame '
|
|
118
|
+
. 'is the payload — one JSON object, {} when the function '
|
|
119
|
+
. 'takes nothing. pp.socket sends it on open.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
$this->clients->offsetSet($conn, $state);
|
|
125
|
+
$this->openConnections++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public function onMessage(ConnectionInterface $from, $msg): void
|
|
129
|
+
{
|
|
130
|
+
if (!$this->clients->offsetExists($from)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
$state = $this->clients[$from];
|
|
135
|
+
$state->lastActivity = microtime(true);
|
|
136
|
+
|
|
137
|
+
if (strlen($msg) > self::maxMessageBytes()) {
|
|
138
|
+
$state->socket?->handleClose();
|
|
139
|
+
$from->close(1009);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The argument frame: one JSON object, before anything else.
|
|
144
|
+
if ($state->socket === null) {
|
|
145
|
+
$this->cancelArgsTimer($state);
|
|
146
|
+
|
|
147
|
+
$args = json_decode($msg, true);
|
|
148
|
+
if (!is_array($args) || array_is_list($args) && $args !== []) {
|
|
149
|
+
$this->refuse(
|
|
150
|
+
$from,
|
|
151
|
+
'The first frame of a socket is not a JSON object. '
|
|
152
|
+
. 'Arguments are named, so they arrive as { "room": ... } — '
|
|
153
|
+
. 'pp.socket("name", { room }) is what sends them.'
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
$socket = new Socket($from, $state->entry['name'], $state->payload);
|
|
159
|
+
$state->socket = $socket;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
($state->entry['handler'])($socket, $args);
|
|
163
|
+
} catch (Throwable $e) {
|
|
164
|
+
$this->reportHandlerFailure($state->entry['name'], $socket, $e);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!$this->allowMessage($state)) {
|
|
170
|
+
$state->socket->error('Too many messages. Slow down.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
$value = json_decode($msg, true);
|
|
175
|
+
if ($value === null && json_last_error() !== JSON_ERROR_NONE) {
|
|
176
|
+
// A frame the handler cannot read is a client bug worth
|
|
177
|
+
// surfacing, and it travels back as the error frame.
|
|
178
|
+
$state->socket->error(
|
|
179
|
+
'`' . $state->entry['name'] . '` could not read a frame. '
|
|
180
|
+
. 'Each frame is one JSON value.'
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
$state->socket->handleMessage($value);
|
|
187
|
+
} catch (Throwable $e) {
|
|
188
|
+
$this->reportHandlerFailure($state->entry['name'], $state->socket, $e);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public function onClose(ConnectionInterface $conn): void
|
|
193
|
+
{
|
|
194
|
+
if (!$this->clients->offsetExists($conn)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
$state = $this->clients[$conn];
|
|
199
|
+
$this->cancelArgsTimer($state);
|
|
200
|
+
$this->clients->offsetUnset($conn);
|
|
201
|
+
$this->openConnections--;
|
|
202
|
+
|
|
203
|
+
$state->socket?->handleClose();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public function onError(ConnectionInterface $conn, Exception $e): void
|
|
207
|
+
{
|
|
208
|
+
echo "Socket connection error: {$e->getMessage()}" . PHP_EOL;
|
|
209
|
+
$conn->close();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ==== Handshake ====
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @return array{name: string, handler: callable, requireAuth: bool, allowedRoles: string[]}|string
|
|
216
|
+
*/
|
|
217
|
+
private function resolveEntry(ConnectionInterface $conn): array|string
|
|
218
|
+
{
|
|
219
|
+
$query = [];
|
|
220
|
+
$request = $conn->httpRequest ?? null;
|
|
221
|
+
if ($request !== null) {
|
|
222
|
+
parse_str($request->getUri()->getQuery(), $query);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
$name = trim((string) ($query['name'] ?? ''));
|
|
226
|
+
if ($name === '') {
|
|
227
|
+
return 'This connection named no socket. Open it as '
|
|
228
|
+
. 'pp.socket("name", { ... }) — the client runtime sends the '
|
|
229
|
+
. 'name in the `name` query parameter.';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
$entry = SocketRegistry::get($name);
|
|
233
|
+
if ($entry === null) {
|
|
234
|
+
return "No socket named `$name`. Register it in "
|
|
235
|
+
. 'src/Lib/Websocket/sockets.php with SocketRegistry::register() — '
|
|
236
|
+
. 'the name the client connects with is the registered one.';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return $entry;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The verified auth payload from the handshake's auth cookie, or null.
|
|
244
|
+
* Verification is pure JWT (`Auth::verifyToken`), so it works in this
|
|
245
|
+
* long-running process without a PHP session.
|
|
246
|
+
*/
|
|
247
|
+
private function verifiedPayload(ConnectionInterface $conn): mixed
|
|
248
|
+
{
|
|
249
|
+
try {
|
|
250
|
+
$request = $conn->httpRequest ?? null;
|
|
251
|
+
if ($request === null) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
$cookies = self::parseCookies($request->getHeaderLine('Cookie'));
|
|
256
|
+
|
|
257
|
+
$authCookieName = strtolower(preg_replace(
|
|
258
|
+
'/\s+/',
|
|
259
|
+
'_',
|
|
260
|
+
trim(Env::string('AUTH_COOKIE_NAME', 'auth_cookie_name_d36e5'))
|
|
261
|
+
));
|
|
262
|
+
|
|
263
|
+
$jwt = $cookies[$authCookieName] ?? null;
|
|
264
|
+
if ($jwt === null || $jwt === '') {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return Auth::getInstance()->verifyToken($jwt);
|
|
269
|
+
} catch (Throwable) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param array{name: string, requireAuth: bool, allowedRoles: string[]} $entry
|
|
276
|
+
*/
|
|
277
|
+
private function authorize(array $entry, mixed $payload): ?string
|
|
278
|
+
{
|
|
279
|
+
if ($payload !== null) {
|
|
280
|
+
if (!empty($entry['allowedRoles'])) {
|
|
281
|
+
$currentRole = null;
|
|
282
|
+
|
|
283
|
+
if (is_scalar($payload)) {
|
|
284
|
+
$currentRole = $payload;
|
|
285
|
+
} else {
|
|
286
|
+
$roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
|
|
287
|
+
|
|
288
|
+
if (is_object($payload)) {
|
|
289
|
+
$currentRole = $payload->$roleKey ?? null;
|
|
290
|
+
} elseif (is_array($payload)) {
|
|
291
|
+
$currentRole = $payload[$roleKey] ?? null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if ($currentRole === null || !in_array($currentRole, $entry['allowedRoles'], true)) {
|
|
296
|
+
return "The socket `{$entry['name']}` is not available to this account.";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if ($entry['requireAuth'] || !empty($entry['allowedRoles'])) {
|
|
304
|
+
return "The socket `{$entry['name']}` needs a signed-in session. "
|
|
305
|
+
. 'It is registered with requireAuth, so it answers only while '
|
|
306
|
+
. 'the browser carries one.';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Anti-CSWSH origin check. NOT authentication: a browser cannot forge
|
|
314
|
+
* the Origin header, so this blocks cross-site script-driven handshakes;
|
|
315
|
+
* a raw client can send any origin, which is exactly why auth is a
|
|
316
|
+
* separate gate.
|
|
317
|
+
*/
|
|
318
|
+
private function isOriginAllowed(ConnectionInterface $conn): bool
|
|
319
|
+
{
|
|
320
|
+
$request = $conn->httpRequest ?? null;
|
|
321
|
+
$origin = rtrim(trim((string) ($request?->getHeaderLine('Origin') ?? '')), '/');
|
|
322
|
+
|
|
323
|
+
if ($origin === '') {
|
|
324
|
+
// No Origin header: tolerate local tooling in dev, reject in production.
|
|
325
|
+
return !self::isProduction();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
$parts = parse_url($origin);
|
|
329
|
+
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
$host = strtolower($parts['host']);
|
|
334
|
+
if (!self::isProduction() && in_array($host, ['localhost', '127.0.0.1'], true)) {
|
|
335
|
+
return strtolower($parts['scheme']) === 'http';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
$allowedOrigins = [];
|
|
339
|
+
foreach (['WEBSOCKET_ALLOWED_ORIGINS', 'CORS_ALLOWED_ORIGINS', 'APP_BASE_URL'] as $envName) {
|
|
340
|
+
foreach (self::parseOriginList(Env::string($envName, '')) as $allowed) {
|
|
341
|
+
$allowedOrigins[] = $allowed;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Same-origin fallback from the Host header is a development
|
|
346
|
+
// convenience only — production must name its origins explicitly.
|
|
347
|
+
if (!self::isProduction() && $request !== null) {
|
|
348
|
+
$hostHeader = trim($request->getHeaderLine('Host'));
|
|
349
|
+
if ($hostHeader !== '') {
|
|
350
|
+
$allowedOrigins[] = 'http://' . $hostHeader;
|
|
351
|
+
$allowedOrigins[] = 'https://' . $hostHeader;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return in_array($origin, $allowedOrigins, true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Refuse after the handshake: the error frame, then the close, so the
|
|
360
|
+
* browser gets a readable message instead of a bare close code.
|
|
361
|
+
*/
|
|
362
|
+
private function refuse(ConnectionInterface $conn, string $message): void
|
|
363
|
+
{
|
|
364
|
+
if ($this->clients->offsetExists($conn)) {
|
|
365
|
+
$state = $this->clients[$conn];
|
|
366
|
+
$this->cancelArgsTimer($state);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
$conn->send(json_encode(['error' => $message], JSON_UNESCAPED_UNICODE));
|
|
371
|
+
} catch (Throwable) {
|
|
372
|
+
// The browser is already gone; the close below is all that is left.
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
$conn->close(1008);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private function reportHandlerFailure(string $name, Socket $socket, Throwable $e): void
|
|
379
|
+
{
|
|
380
|
+
echo "[Socket Error] $name: {$e->getMessage()}" . PHP_EOL;
|
|
381
|
+
|
|
382
|
+
$message = self::isProduction()
|
|
383
|
+
? 'Internal server error'
|
|
384
|
+
: "$name: {$e->getMessage()}";
|
|
385
|
+
|
|
386
|
+
$socket->error($message);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ==== Limits ====
|
|
390
|
+
|
|
391
|
+
private function allowMessage(object $state): bool
|
|
392
|
+
{
|
|
393
|
+
$now = microtime(true);
|
|
394
|
+
$cutoff = $now - self::rateWindowSeconds();
|
|
395
|
+
|
|
396
|
+
$state->messageTimestamps = array_values(array_filter(
|
|
397
|
+
$state->messageTimestamps,
|
|
398
|
+
static fn (float $timestamp): bool => $timestamp > $cutoff,
|
|
399
|
+
));
|
|
400
|
+
|
|
401
|
+
if (count($state->messageTimestamps) >= self::messagesPerWindow()) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
$state->messageTimestamps[] = $now;
|
|
406
|
+
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private function cancelArgsTimer(object $state): void
|
|
411
|
+
{
|
|
412
|
+
if ($state->argsTimer !== null && $this->loop !== null) {
|
|
413
|
+
$this->loop->cancelTimer($state->argsTimer);
|
|
414
|
+
$state->argsTimer = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ==== Settings (same env names and defaults as the reference server) ====
|
|
419
|
+
|
|
420
|
+
private static function isProduction(): bool
|
|
421
|
+
{
|
|
422
|
+
return Env::string('APP_ENV', 'production') === 'production';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private static function idleTimeoutSeconds(): int
|
|
426
|
+
{
|
|
427
|
+
return max(10, Env::int('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private static function maxMessageBytes(): int
|
|
431
|
+
{
|
|
432
|
+
return max(256, Env::int('MAX_WEBSOCKET_MESSAGE_BYTES', 4096));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private static function messagesPerWindow(): int
|
|
436
|
+
{
|
|
437
|
+
return max(1, Env::int('MAX_WEBSOCKET_MESSAGES_PER_WINDOW', 20));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private static function rateWindowSeconds(): int
|
|
441
|
+
{
|
|
442
|
+
return max(1, Env::int('WEBSOCKET_RATE_WINDOW_SECONDS', 10));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private static function maxConnections(): int
|
|
446
|
+
{
|
|
447
|
+
return max(1, Env::int('MAX_WEBSOCKET_CONNECTIONS', 200));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Parse an origin list env value: CSV or a JSON array, the same formats
|
|
452
|
+
* CorsMiddleware accepts.
|
|
453
|
+
*
|
|
454
|
+
* @return string[]
|
|
455
|
+
*/
|
|
456
|
+
private static function parseOriginList(string $raw): array
|
|
457
|
+
{
|
|
458
|
+
$raw = trim($raw);
|
|
459
|
+
if ($raw === '' || $raw === '[]') {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
$values = null;
|
|
464
|
+
if ($raw[0] === '[') {
|
|
465
|
+
$decoded = json_decode($raw, true);
|
|
466
|
+
if (is_array($decoded)) {
|
|
467
|
+
$values = array_map('strval', $decoded);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
$values ??= explode(',', $raw);
|
|
472
|
+
|
|
473
|
+
$origins = [];
|
|
474
|
+
foreach ($values as $value) {
|
|
475
|
+
$value = rtrim(trim($value), '/');
|
|
476
|
+
if ($value !== '') {
|
|
477
|
+
$origins[] = $value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return $origins;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @return array<string, string>
|
|
486
|
+
*/
|
|
487
|
+
private static function parseCookies(string $header): array
|
|
488
|
+
{
|
|
489
|
+
$cookies = [];
|
|
490
|
+
|
|
491
|
+
foreach (explode(';', $header) as $pair) {
|
|
492
|
+
$parts = explode('=', trim($pair), 2);
|
|
493
|
+
if (count($parts) === 2 && $parts[0] !== '') {
|
|
494
|
+
$cookies[strtolower($parts[0])] = urldecode($parts[1]);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return $cookies;
|
|
499
|
+
}
|
|
500
|
+
}
|