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.
Files changed (60) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +191 -0
  3. package/dist/AGENTS.md +588 -193
  4. package/dist/bootstrap.php +426 -213
  5. package/dist/index.js +2 -2
  6. package/dist/phpunit.xml +25 -0
  7. package/dist/postcss.config.js +4 -2
  8. package/dist/prisma-php.js +2 -2
  9. package/dist/public/.htaccess +1 -1
  10. package/dist/public/js/main.js +13 -1
  11. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  12. package/dist/settings/auto-swagger-docs.ts +10 -10
  13. package/dist/settings/bs-config.ts +47 -27
  14. package/dist/settings/build.ts +2 -32
  15. package/dist/settings/component-map.ts +476 -0
  16. package/dist/settings/files-list.ts +2 -2
  17. package/dist/settings/project-name.ts +3 -6
  18. package/dist/settings/restart-mcp.ts +2 -2
  19. package/dist/settings/restart-websocket.ts +2 -2
  20. package/dist/settings/run-postcss.ts +205 -0
  21. package/dist/settings/run-tests.ts +35 -0
  22. package/dist/settings/swagger-config.ts +3 -3
  23. package/dist/settings/utils.ts +3 -3
  24. package/dist/src/Lib/Auth/Auth.php +62 -34
  25. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  26. package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
  27. package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
  28. package/dist/src/Lib/Websocket/Socket.php +170 -0
  29. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  30. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  31. package/dist/src/Lib/Websocket/sockets.php +50 -0
  32. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  33. package/dist/src/app/globals.css +3 -1
  34. package/dist/src/app/layout.php +1 -1
  35. package/dist/tests/AuthTest.php +59 -0
  36. package/dist/tests/ConnectionManagerTest.php +277 -0
  37. package/dist/tests/CsrfTest.php +119 -0
  38. package/dist/tests/DeferComponentRootsTest.php +147 -0
  39. package/dist/tests/FeaturesTest.php +41 -0
  40. package/dist/tests/README.md +119 -0
  41. package/dist/tests/RpcWireContractTest.php +101 -0
  42. package/dist/tests/SocketPoolTest.php +69 -0
  43. package/dist/tests/SocketRegistryTest.php +77 -0
  44. package/dist/tests/SocketTest.php +124 -0
  45. package/dist/tests/SocketsRegistrationTest.php +40 -0
  46. package/dist/tests/Support/FakeConnection.php +62 -0
  47. package/dist/tests/Support/Features.php +38 -0
  48. package/dist/tests/Support/RequiresFeature.php +26 -0
  49. package/dist/tests/bootstrap.php +44 -0
  50. package/dist/ts/main.ts +21 -3
  51. package/dist/ts/tailwind-merge.ts +13 -0
  52. package/package.json +4 -4
  53. package/dist/README.md +0 -213
  54. package/dist/public/js/pp-reactive-v2.js +0 -1
  55. package/dist/settings/bs-config.json +0 -6
  56. package/dist/settings/class-imports.ts +0 -165
  57. package/dist/settings/class-log.ts +0 -244
  58. package/dist/settings/component-import-checker.ts +0 -90
  59. package/dist/settings/files-list.json +0 -1
  60. 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
- $this->clients->offsetSet($conn, true);
24
- echo "New connection! ({$conn->resourceId})";
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
- foreach ($this->clients as $client) {
30
- if ($from !== $client) {
31
- $client->send($msg);
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
- echo "Connection {$conn->resourceId} has disconnected";
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 "An error has occurred: {$e->getMessage()}";
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
+ }