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.
Files changed (41) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +80 -33
  3. package/dist/AGENTS.md +59 -25
  4. package/dist/bootstrap.php +207 -187
  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/public/.htaccess +1 -1
  9. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  10. package/dist/settings/bs-config.ts +44 -1
  11. package/dist/settings/run-postcss.ts +205 -0
  12. package/dist/settings/run-tests.ts +35 -0
  13. package/dist/src/Lib/Auth/Auth.php +12 -25
  14. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  15. package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
  16. package/dist/src/Lib/Websocket/Socket.php +170 -0
  17. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  18. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  19. package/dist/src/Lib/Websocket/sockets.php +50 -0
  20. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  21. package/dist/src/app/globals.css +3 -1
  22. package/dist/src/app/layout.php +1 -1
  23. package/dist/tests/AuthTest.php +59 -0
  24. package/dist/tests/ConnectionManagerTest.php +277 -0
  25. package/dist/tests/CsrfTest.php +119 -0
  26. package/dist/tests/DeferComponentRootsTest.php +147 -0
  27. package/dist/tests/FeaturesTest.php +41 -0
  28. package/dist/tests/README.md +119 -0
  29. package/dist/tests/RpcWireContractTest.php +101 -0
  30. package/dist/tests/SocketPoolTest.php +69 -0
  31. package/dist/tests/SocketRegistryTest.php +77 -0
  32. package/dist/tests/SocketTest.php +124 -0
  33. package/dist/tests/SocketsRegistrationTest.php +40 -0
  34. package/dist/tests/Support/FakeConnection.php +62 -0
  35. package/dist/tests/Support/Features.php +38 -0
  36. package/dist/tests/Support/RequiresFeature.php +26 -0
  37. package/dist/tests/bootstrap.php +44 -0
  38. package/dist/ts/main.ts +5 -8
  39. package/dist/ts/tailwind-merge.ts +13 -0
  40. package/package.json +4 -4
  41. package/dist/public/js/pp-reactive-v2.js +0 -1
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Lib\Websocket;
6
+
7
+ /**
8
+ * A broadcast pool: one `Socket` per open connection.
9
+ *
10
+ * How a handler shares its connection with the room it belongs to: keep one
11
+ * pool in shared state, `add` each socket when its conversation starts, and
12
+ * `broadcast` fans one value out to everyone. Connections whose browser is
13
+ * gone are pruned on the way. Keep authenticated and guest traffic in
14
+ * separate pools so a private broadcast can never reach a guest connection.
15
+ */
16
+ final class SocketPool
17
+ {
18
+ /** @var Socket[] */
19
+ private array $sockets = [];
20
+
21
+ public function count(): int
22
+ {
23
+ return count($this->sockets);
24
+ }
25
+
26
+ public function add(Socket $socket): void
27
+ {
28
+ $this->sockets[] = $socket;
29
+ }
30
+
31
+ public function discard(Socket $socket): void
32
+ {
33
+ $this->sockets = array_values(array_filter(
34
+ $this->sockets,
35
+ static fn (Socket $candidate): bool => $candidate !== $socket,
36
+ ));
37
+ }
38
+
39
+ /**
40
+ * Send one value to everyone, pruning closed connections.
41
+ */
42
+ public function broadcast(mixed $value): void
43
+ {
44
+ foreach ($this->sockets as $socket) {
45
+ if (!$socket->send($value)) {
46
+ $this->discard($socket);
47
+ }
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,88 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Lib\Websocket;
6
+
7
+ use InvalidArgumentException;
8
+
9
+ /**
10
+ * The named-socket registry: what `pp.socket("name", ...)` connects to.
11
+ *
12
+ * The client connects with a name and nothing else, so socket names are
13
+ * unique application-wide and a duplicate is refused at registration time.
14
+ * Registrations live in `src/Lib/Websocket/sockets.php`, which the socket
15
+ * server loads on startup.
16
+ */
17
+ final class SocketRegistry
18
+ {
19
+ /**
20
+ * @var array<string, array{
21
+ * name: string,
22
+ * handler: callable,
23
+ * requireAuth: bool,
24
+ * allowedRoles: string[],
25
+ * }>
26
+ */
27
+ private static array $sockets = [];
28
+
29
+ /**
30
+ * Register a function as a named socket.
31
+ *
32
+ * The handler receives `(Socket $socket, array $args)`: the open
33
+ * connection and the argument payload from the connection's first frame.
34
+ *
35
+ * - `requireAuth: true` refuses the connection unless the handshake
36
+ * carries a valid auth cookie, before the handler runs.
37
+ * - `allowedRoles: [...]` adds RBAC on the verified payload, the same
38
+ * rule as `#[Exposed(allowedRoles: [...])]`.
39
+ *
40
+ * @param string[] $allowedRoles
41
+ */
42
+ public static function register(
43
+ string $name,
44
+ callable $handler,
45
+ bool $requireAuth = false,
46
+ array $allowedRoles = [],
47
+ ): void {
48
+ if ($name === '') {
49
+ throw new InvalidArgumentException('A socket needs a non-empty name.');
50
+ }
51
+
52
+ if (isset(self::$sockets[$name])) {
53
+ throw new InvalidArgumentException(
54
+ "Two sockets are named `$name`. The client connects with a "
55
+ . 'name and nothing else, so socket names must be unique '
56
+ . 'application-wide.'
57
+ );
58
+ }
59
+
60
+ self::$sockets[$name] = [
61
+ 'name' => $name,
62
+ 'handler' => $handler,
63
+ 'requireAuth' => $requireAuth,
64
+ 'allowedRoles' => array_values($allowedRoles),
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * @return array{
70
+ * name: string,
71
+ * handler: callable,
72
+ * requireAuth: bool,
73
+ * allowedRoles: string[],
74
+ * }|null
75
+ */
76
+ public static function get(string $name): ?array
77
+ {
78
+ return self::$sockets[$name] ?? null;
79
+ }
80
+
81
+ /**
82
+ * @return string[]
83
+ */
84
+ public static function names(): array
85
+ {
86
+ return array_keys(self::$sockets);
87
+ }
88
+ }
@@ -0,0 +1,50 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * Named-socket registrations: the server half of `pp.socket(...)`.
7
+ *
8
+ * This file is loaded once by `websocket-server.php` on startup. Register
9
+ * each socket the app serves here; the name the client connects with is the
10
+ * registered one, and names are unique application-wide.
11
+ *
12
+ * From the browser:
13
+ *
14
+ * ```js
15
+ * const sock = pp.socket("echo", { label: "you" }, {
16
+ * onMessage: (value) => append(value),
17
+ * });
18
+ * sock.send("hello");
19
+ * ```
20
+ */
21
+
22
+ use Lib\Websocket\Socket;
23
+ use Lib\Websocket\SocketPool;
24
+ use Lib\Websocket\SocketRegistry;
25
+
26
+ // A minimal example: echo every frame back, prefixed with the label the
27
+ // page opened the socket with.
28
+ SocketRegistry::register('echo', function (Socket $socket, array $args): void {
29
+ $label = is_string($args['label'] ?? null) ? $args['label'] : 'echo';
30
+
31
+ $socket->onMessage(function (mixed $value) use ($socket, $label): void {
32
+ $text = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
33
+ $socket->send("$label: $text");
34
+ });
35
+ });
36
+
37
+ // A broadcast example: everyone connected to `chat` hears everyone else.
38
+ $chatRoom = new SocketPool();
39
+
40
+ SocketRegistry::register('chat', function (Socket $socket, array $args) use ($chatRoom): void {
41
+ $chatRoom->add($socket);
42
+
43
+ $socket->onMessage(function (mixed $value) use ($chatRoom): void {
44
+ $chatRoom->broadcast($value);
45
+ });
46
+
47
+ $socket->onClose(function () use ($chatRoom, $socket): void {
48
+ $chatRoom->discard($socket);
49
+ });
50
+ });
@@ -54,7 +54,10 @@ if ($probe === false) {
54
54
  fclose($probe);
55
55
 
56
56
  // ── Build app ────────────────────────────────────────────────────────────────
57
- $manager = new ConnectionManager(); // your app component
57
+ // Named-socket registrations: the functions `pp.socket("name", ...)` reaches.
58
+ require __DIR__ . '/sockets.php';
59
+
60
+ $manager = new ConnectionManager(); // The PulsePoint named-socket wire.
58
61
  $server = IoServer::factory(
59
62
  new HttpServer(new WsServer($manager)),
60
63
  $port,
@@ -73,14 +76,18 @@ echo " Port: {$port}" . PHP_EOL;
73
76
  echo " URL: {$url}" . PHP_EOL;
74
77
  echo " PID: {$pid}" . PHP_EOL;
75
78
  echo " Started: {$ts}" . PHP_EOL;
79
+ echo " Sockets: " . (implode(', ', \Lib\Websocket\SocketRegistry::names()) ?: '(none registered)') . PHP_EOL;
76
80
 
77
81
  // ── Graceful shutdown & periodic logs (if loop available) ────────────────────
78
82
  $loop = property_exists($server, 'loop') ? $server->loop : null;
79
83
  if ($loop instanceof LoopInterface) {
84
+ // First-frame timeout and idle sweep need the loop's timers.
85
+ $manager->attachLoop($loop);
86
+
80
87
  // Periodic stats every 60s
81
- $loop->addPeriodicTimer(60, function () use ($ok) {
88
+ $loop->addPeriodicTimer(60, function () use ($ok, $manager) {
82
89
  $mem = function_exists('memory_get_usage') ? number_format(memory_get_usage(true) / 1048576, 2) . ' MB' : 'n/a';
83
- $msg = "✓ Heartbeat — memory: {$mem}";
90
+ $msg = "✓ Heartbeat — connections: {$manager->openConnectionCount()}, memory: {$mem}";
84
91
  echo $ok($msg) . PHP_EOL;
85
92
  });
86
93
 
@@ -1,4 +1,6 @@
1
- @import "tailwindcss";
1
+ @import "tailwindcss" source(none);
2
+ @source "../";
3
+ @source "../../ts";
2
4
 
3
5
  :root {
4
6
  --background: #ffffff;
@@ -15,7 +15,7 @@ MainLayout::$description = !empty(MainLayout::$description) ? MainLayout::$descr
15
15
  <!-- Dynamic Header Scripts -->
16
16
  </head>
17
17
 
18
- <body pp-spa="true" style="opacity:0;pointer-events:none;user-select:none;transition:opacity .18s ease-out;">
18
+ <body style="opacity:0;pointer-events:none;user-select:none;transition:opacity .18s ease-out;">
19
19
  <?= MainLayout::$children; ?>
20
20
  <!-- Dynamic Footer Scripts -->
21
21
  </body>
@@ -0,0 +1,59 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use Firebase\JWT\JWT;
8
+ use Lib\Auth\Auth;
9
+ use PHPUnit\Framework\TestCase;
10
+ use PP\Env;
11
+
12
+ /**
13
+ * The JWT session tokens both wires trust: RPC auth reads them through the
14
+ * request session, and the socket server verifies the handshake cookie with
15
+ * `verifyToken(...)` alone.
16
+ */
17
+ final class AuthTest extends TestCase
18
+ {
19
+ public function testSignInReturnsATokenThatVerifiesBackToThePayload(): void
20
+ {
21
+ $auth = Auth::getInstance();
22
+
23
+ $jwt = $auth->signIn('admin');
24
+
25
+ self::assertSame('admin', $auth->verifyToken($jwt));
26
+ }
27
+
28
+ public function testATamperedTokenVerifiesToNull(): void
29
+ {
30
+ $auth = Auth::getInstance();
31
+ $jwt = $auth->signIn('admin');
32
+
33
+ self::assertNull($auth->verifyToken($jwt . 'x'));
34
+ self::assertNull($auth->verifyToken('not-a-jwt'));
35
+ self::assertNull($auth->verifyToken(null));
36
+ }
37
+
38
+ public function testAnExpiredTokenVerifiesToNull(): void
39
+ {
40
+ $expired = JWT::encode(
41
+ [Auth::PAYLOAD_NAME => 'admin', 'exp' => time() - 60],
42
+ Env::string('AUTH_SECRET', ''),
43
+ 'HS256'
44
+ );
45
+
46
+ self::assertNull(Auth::getInstance()->verifyToken($expired));
47
+ }
48
+
49
+ public function testATokenSignedWithADifferentSecretVerifiesToNull(): void
50
+ {
51
+ $forged = JWT::encode(
52
+ [Auth::PAYLOAD_NAME => 'admin', 'exp' => time() + 3600],
53
+ 'a-different-32-byte-signing-secret-key',
54
+ 'HS256'
55
+ );
56
+
57
+ self::assertNull(Auth::getInstance()->verifyToken($forged));
58
+ }
59
+ }