create-prisma-php-app 5.0.0-alpha.8 → 5.0.0
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 +191 -0
- package/dist/AGENTS.md +588 -193
- package/dist/bootstrap.php +426 -213
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/prisma-php.js +2 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/main.js +13 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/auto-swagger-docs.ts +10 -10
- package/dist/settings/bs-config.ts +47 -27
- package/dist/settings/build.ts +2 -32
- package/dist/settings/component-map.ts +476 -0
- package/dist/settings/files-list.ts +2 -2
- package/dist/settings/project-name.ts +3 -6
- package/dist/settings/restart-mcp.ts +2 -2
- package/dist/settings/restart-websocket.ts +2 -2
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/settings/swagger-config.ts +3 -3
- package/dist/settings/utils.ts +3 -3
- package/dist/src/Lib/Auth/Auth.php +62 -34
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
- package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
- 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 +21 -3
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- package/dist/README.md +0 -213
- package/dist/public/js/pp-reactive-v2.js +0 -1
- package/dist/settings/bs-config.json +0 -6
- package/dist/settings/class-imports.ts +0 -165
- package/dist/settings/class-log.ts +0 -244
- package/dist/settings/component-import-checker.ts +0 -90
- package/dist/settings/files-list.json +0 -1
- package/dist/settings/prisma-schema-config.json +0 -16
|
@@ -6,42 +6,495 @@ namespace Lib\Websocket;
|
|
|
6
6
|
|
|
7
7
|
use Ratchet\MessageComponentInterface;
|
|
8
8
|
use Ratchet\ConnectionInterface;
|
|
9
|
+
use React\EventLoop\LoopInterface;
|
|
10
|
+
use React\EventLoop\TimerInterface;
|
|
11
|
+
use Lib\Auth\Auth;
|
|
12
|
+
use PP\Env;
|
|
9
13
|
use Exception;
|
|
10
14
|
use SplObjectStorage;
|
|
15
|
+
use Throwable;
|
|
11
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
|
+
*/
|
|
12
37
|
class ConnectionManager implements MessageComponentInterface
|
|
13
38
|
{
|
|
39
|
+
private const ARGS_TIMEOUT_SECONDS = 10;
|
|
40
|
+
|
|
41
|
+
/** @var SplObjectStorage<ConnectionInterface, object> */
|
|
14
42
|
protected SplObjectStorage $clients;
|
|
15
43
|
|
|
44
|
+
private ?LoopInterface $loop = null;
|
|
45
|
+
private ?TimerInterface $idleSweepTimer = null;
|
|
46
|
+
private int $openConnections = 0;
|
|
47
|
+
|
|
16
48
|
public function __construct()
|
|
17
49
|
{
|
|
18
50
|
$this->clients = new SplObjectStorage();
|
|
19
51
|
}
|
|
20
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
|
+
|
|
21
80
|
public function onOpen(ConnectionInterface $conn): void
|
|
22
81
|
{
|
|
23
|
-
|
|
24
|
-
|
|
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++;
|
|
25
126
|
}
|
|
26
127
|
|
|
27
128
|
public function onMessage(ConnectionInterface $from, $msg): void
|
|
28
129
|
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
32
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);
|
|
33
189
|
}
|
|
34
190
|
}
|
|
35
191
|
|
|
36
192
|
public function onClose(ConnectionInterface $conn): void
|
|
37
193
|
{
|
|
194
|
+
if (!$this->clients->offsetExists($conn)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
$state = $this->clients[$conn];
|
|
199
|
+
$this->cancelArgsTimer($state);
|
|
38
200
|
$this->clients->offsetUnset($conn);
|
|
39
|
-
|
|
201
|
+
$this->openConnections--;
|
|
202
|
+
|
|
203
|
+
$state->socket?->handleClose();
|
|
40
204
|
}
|
|
41
205
|
|
|
42
206
|
public function onError(ConnectionInterface $conn, Exception $e): void
|
|
43
207
|
{
|
|
44
|
-
echo "
|
|
208
|
+
echo "Socket connection error: {$e->getMessage()}" . PHP_EOL;
|
|
45
209
|
$conn->close();
|
|
46
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
|
+
}
|
|
47
500
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Lib\Websocket;
|
|
6
|
+
|
|
7
|
+
use Ratchet\ConnectionInterface;
|
|
8
|
+
use InvalidArgumentException;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One open connection, as a socket handler holds it.
|
|
12
|
+
*
|
|
13
|
+
* The server half of `pp.socket(name, args, handlers)`. A handler receives
|
|
14
|
+
* this and the argument payload once the first frame arrives, wires its
|
|
15
|
+
* callbacks, and talks JSON frames both ways:
|
|
16
|
+
*
|
|
17
|
+
* ```php
|
|
18
|
+
* SocketRegistry::register('echo', function (Socket $socket, array $args): void {
|
|
19
|
+
* $socket->onMessage(fn (mixed $value) => $socket->send($value));
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The frame shape `{"error": "..."}` (that key alone) is reserved by the
|
|
24
|
+
* wire: it is how the server reports a failure inside an open connection,
|
|
25
|
+
* and the client runtime routes it to `onError` followed by the close.
|
|
26
|
+
* `send()` therefore refuses to emit it as an ordinary message.
|
|
27
|
+
*/
|
|
28
|
+
final class Socket
|
|
29
|
+
{
|
|
30
|
+
private bool $closed = false;
|
|
31
|
+
|
|
32
|
+
/** @var callable|null fn(mixed $value): void */
|
|
33
|
+
private $messageListener = null;
|
|
34
|
+
|
|
35
|
+
/** @var callable|null fn(): void */
|
|
36
|
+
private $closeListener = null;
|
|
37
|
+
|
|
38
|
+
public function __construct(
|
|
39
|
+
private readonly ConnectionInterface $conn,
|
|
40
|
+
private readonly string $name,
|
|
41
|
+
private readonly mixed $authPayload = null,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
public function name(): string
|
|
45
|
+
{
|
|
46
|
+
return $this->name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The verified auth payload of the connection, or null for a guest.
|
|
51
|
+
*/
|
|
52
|
+
public function payload(): mixed
|
|
53
|
+
{
|
|
54
|
+
return $this->authPayload;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public function isOpen(): bool
|
|
58
|
+
{
|
|
59
|
+
return !$this->closed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send one JSON value. False means nobody is listening any more — the
|
|
64
|
+
* browser navigated away or closed the tab. That is the signal to stop,
|
|
65
|
+
* not an error to report.
|
|
66
|
+
*/
|
|
67
|
+
public function send(mixed $value): bool
|
|
68
|
+
{
|
|
69
|
+
if ($this->closed) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (self::isReservedErrorShape($value)) {
|
|
74
|
+
throw new InvalidArgumentException(
|
|
75
|
+
'The frame shape {"error": "..."} is reserved for failures. '
|
|
76
|
+
. 'Wrap the value or rename the key.'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
$frame = json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
81
|
+
if ($frame === false) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$this->conn->send($frame);
|
|
86
|
+
|
|
87
|
+
return !$this->closed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Called with each decoded JSON value the browser sends.
|
|
92
|
+
*/
|
|
93
|
+
public function onMessage(callable $listener): void
|
|
94
|
+
{
|
|
95
|
+
$this->messageListener = $listener;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Called once, when the connection closes for any reason.
|
|
100
|
+
*/
|
|
101
|
+
public function onClose(callable $listener): void
|
|
102
|
+
{
|
|
103
|
+
$this->closeListener = $listener;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Say goodbye mid-conversation. Close code 1000, a normal closure.
|
|
108
|
+
*/
|
|
109
|
+
public function close(): void
|
|
110
|
+
{
|
|
111
|
+
if ($this->closed) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
$this->conn->close(1000);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The error frame, then the close. The conversation is over.
|
|
120
|
+
* Framework/handler use for failures meant to reach `onError`.
|
|
121
|
+
*/
|
|
122
|
+
public function error(string $message): void
|
|
123
|
+
{
|
|
124
|
+
if ($this->closed) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
$this->conn->send(json_encode(['error' => $message], JSON_UNESCAPED_UNICODE));
|
|
129
|
+
$this->conn->close(1008);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @internal Called by the connection manager on each inbound frame. */
|
|
133
|
+
public function handleMessage(mixed $value): void
|
|
134
|
+
{
|
|
135
|
+
if ($this->messageListener !== null) {
|
|
136
|
+
($this->messageListener)($value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @internal Called by the connection manager when the wire closes. */
|
|
141
|
+
public function handleClose(): void
|
|
142
|
+
{
|
|
143
|
+
if ($this->closed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
$this->closed = true;
|
|
148
|
+
|
|
149
|
+
if ($this->closeListener !== null) {
|
|
150
|
+
($this->closeListener)();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @internal Close with a specific code without emitting a frame. */
|
|
155
|
+
public function closeWithCode(int $code): void
|
|
156
|
+
{
|
|
157
|
+
if ($this->closed) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
$this->conn->close($code);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public static function isReservedErrorShape(mixed $value): bool
|
|
165
|
+
{
|
|
166
|
+
return is_array($value)
|
|
167
|
+
&& array_keys($value) === ['error']
|
|
168
|
+
&& is_string($value['error']);
|
|
169
|
+
}
|
|
170
|
+
}
|