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
@@ -31,8 +31,21 @@ use PP\Attributes\Exposed;
31
31
  use PP\Attributes\ExposedRegistry;
32
32
  use PP\Streaming\SSE;
33
33
  use PP\Security\RateLimiter;
34
+ use PP\Security\Csrf;
34
35
  use PP\Env;
35
36
 
37
+ /**
38
+ * An RPC failure meant for the wire: message plus HTTP status, serialized as
39
+ * `{"error": "..."}` — the shape the PulsePoint runtime reads.
40
+ */
41
+ final class RpcError extends RuntimeException
42
+ {
43
+ public function __construct(string $message, public readonly int $status)
44
+ {
45
+ parent::__construct($message);
46
+ }
47
+ }
48
+
36
49
  final class Bootstrap extends RuntimeException
37
50
  {
38
51
  public static string $contentToInclude = '';
@@ -43,7 +56,6 @@ final class Bootstrap extends RuntimeException
43
56
  public static bool $isContentIncluded = false;
44
57
  public static bool $isChildContentIncluded = false;
45
58
  public static bool $isContentVariableIncluded = false;
46
- public static bool $secondRequestC69CD = false;
47
59
  public static array $requestFilesData = [];
48
60
 
49
61
  private string $context;
@@ -94,9 +106,7 @@ final class Bootstrap extends RuntimeException
94
106
  MainLayout::init();
95
107
  ErrorHandler::registerHandlers();
96
108
 
97
- self::setCsrfCookie();
98
-
99
- self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
109
+ Csrf::ensureCookie();
100
110
 
101
111
  $contentInfo = self::determineContentToInclude();
102
112
  self::$contentToInclude = $contentInfo['path'] ?? '';
@@ -137,73 +147,94 @@ final class Bootstrap extends RuntimeException
137
147
  ErrorHandler::checkFatalError();
138
148
  }
139
149
 
140
- private static function setCsrfCookie(): void
150
+ /**
151
+ * Anti-CSRF origin check for RPC calls. NOT authentication: a browser
152
+ * cannot forge the Origin header, so this blocks cross-site script-driven
153
+ * calls; raw clients are handled by the CSRF token and auth gates.
154
+ *
155
+ * @return string|null An error message, or null when the origin is fine.
156
+ */
157
+ private static function validateRpcOrigin(): ?string
141
158
  {
142
- $secret = Env::string('FUNCTION_CALL_SECRET', '');
143
-
144
- if ($secret === '') {
145
- throw new RuntimeException('FUNCTION_CALL_SECRET is required for CSRF protection.');
159
+ $origin = rtrim(trim($_SERVER['HTTP_ORIGIN'] ?? ''), '/');
160
+ if ($origin === '') {
161
+ return null;
146
162
  }
147
- $shouldRegenerate = true;
148
-
149
- if (isset($_COOKIE['prisma_php_csrf'])) {
150
- $parts = explode('.', $_COOKIE['prisma_php_csrf']);
151
- if (count($parts) === 2) {
152
- [$nonce, $signature] = $parts;
153
- $expectedSignature = hash_hmac('sha256', $nonce, $secret);
154
163
 
155
- if (hash_equals($expectedSignature, $signature)) {
156
- $shouldRegenerate = false;
157
- }
164
+ if (Env::string('APP_ENV', 'production') !== 'production') {
165
+ if (preg_match('#^http://(localhost|127\.0\.0\.1)(:\d+)?$#i', $origin)) {
166
+ return null;
158
167
  }
159
168
  }
160
169
 
161
- if ($shouldRegenerate) {
162
- $nonce = bin2hex(random_bytes(16));
163
- $signature = hash_hmac('sha256', $nonce, $secret);
164
- $token = $nonce . '.' . $signature;
170
+ $allowedOrigins = [rtrim(Request::$protocol . Request::$domainName, '/')];
165
171
 
166
- setcookie('prisma_php_csrf', $token, [
167
- 'expires' => time() + 3600,
168
- 'path' => '/',
169
- 'secure' => self::isHttpsRequest(),
170
- 'httponly' => false,
171
- 'samesite' => 'Lax',
172
- ]);
172
+ $baseUrl = rtrim(Env::string('APP_BASE_URL', ''), '/');
173
+ if ($baseUrl !== '') {
174
+ $allowedOrigins[] = $baseUrl;
175
+ }
173
176
 
174
- $_COOKIE['prisma_php_csrf'] = $token;
177
+ foreach (self::parseOriginList(Env::string('CORS_ALLOWED_ORIGINS', '')) as $allowed) {
178
+ $allowedOrigins[] = $allowed;
175
179
  }
180
+
181
+ return in_array($origin, $allowedOrigins, true) ? null : 'Invalid origin';
176
182
  }
177
183
 
178
- private static function validateCsrfToken(): void
184
+ /**
185
+ * Parse an origin list env value: CSV or a JSON array, the same formats
186
+ * CorsMiddleware accepts.
187
+ *
188
+ * @return string[]
189
+ */
190
+ private static function parseOriginList(string $raw): array
179
191
  {
180
- $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
181
- $cookieToken = $_COOKIE['prisma_php_csrf'] ?? '';
182
- $secret = Env::string('FUNCTION_CALL_SECRET', '');
183
-
184
- if ($secret === '') {
185
- self::jsonExit(['success' => false, 'error' => 'CSRF secret is not configured']);
192
+ $raw = trim($raw);
193
+ if ($raw === '' || $raw === '[]') {
194
+ return [];
186
195
  }
187
196
 
188
- if (empty($headerToken) || empty($cookieToken)) {
189
- self::jsonExit(['success' => false, 'error' => 'CSRF token missing']);
197
+ $values = null;
198
+ if ($raw[0] === '[') {
199
+ $decoded = json_decode($raw, true);
200
+ if (is_array($decoded)) {
201
+ $values = array_map('strval', $decoded);
202
+ }
190
203
  }
191
204
 
192
- if (!hash_equals($cookieToken, $headerToken)) {
193
- self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
194
- }
205
+ $values ??= explode(',', $raw);
195
206
 
196
- $parts = explode('.', $cookieToken);
197
- if (count($parts) !== 2) {
198
- self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
207
+ $origins = [];
208
+ foreach ($values as $value) {
209
+ $value = rtrim(trim($value), '/');
210
+ if ($value !== '') {
211
+ $origins[] = $value;
212
+ }
199
213
  }
200
214
 
201
- [$nonce, $signature] = $parts;
202
- $expectedSignature = hash_hmac('sha256', $nonce, $secret);
215
+ return $origins;
216
+ }
217
+
218
+ /**
219
+ * RPC bodies are JSON or multipart uploads; any other content type with a
220
+ * body is refused with 415, matching the runtime's error handling.
221
+ */
222
+ private static function isRpcContentTypeAllowed(): bool
223
+ {
224
+ $contentType = strtolower(Request::$contentType);
225
+ if (
226
+ str_starts_with($contentType, 'application/json')
227
+ || str_starts_with($contentType, 'multipart/form-data')
228
+ ) {
229
+ return true;
230
+ }
203
231
 
204
- if (!hash_equals($expectedSignature, $signature)) {
205
- self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
232
+ $contentLength = $_SERVER['CONTENT_LENGTH'] ?? $_SERVER['HTTP_CONTENT_LENGTH'] ?? null;
233
+ if ($contentLength !== null) {
234
+ return (int) $contentLength <= 0;
206
235
  }
236
+
237
+ return strtolower((string) ($_SERVER['HTTP_TRANSFER_ENCODING'] ?? '')) === '';
207
238
  }
208
239
 
209
240
  private static function fileExistsCached(string $path): bool
@@ -950,21 +981,35 @@ final class Bootstrap extends RuntimeException
950
981
  $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
951
982
 
952
983
  if (empty($callbackName)) {
953
- self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
984
+ self::jsonExit(['error' => 'Missing function name'], 400);
954
985
  }
955
986
 
956
- self::validateCsrfToken();
957
-
958
987
  if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
959
- self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
988
+ self::jsonExit(['error' => 'Invalid function name'], 400);
989
+ }
990
+
991
+ if (($error = self::validateRpcOrigin()) !== null) {
992
+ self::jsonExit(['error' => $error], 403);
993
+ }
994
+
995
+ if (!self::isRpcContentTypeAllowed()) {
996
+ self::jsonExit(['error' => 'Invalid content type'], 415);
997
+ }
998
+
999
+ if (($error = Csrf::validateHeaderToken($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '')) !== null) {
1000
+ self::jsonExit(['error' => $error], 403);
960
1001
  }
961
1002
 
962
1003
  $data = self::getRequestData();
963
1004
  $args = self::convertToArrayObject($data);
964
1005
 
965
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
966
- ? self::dispatchMethod($callbackName, $args)
967
- : self::dispatchFunction($callbackName, $args);
1006
+ try {
1007
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
1008
+ ? self::dispatchMethod($callbackName, $args)
1009
+ : self::dispatchFunction($callbackName, $args);
1010
+ } catch (RpcError $e) {
1011
+ self::jsonExit(['error' => $e->getMessage()], $e->status);
1012
+ }
968
1013
 
969
1014
  if ($out instanceof SSE) {
970
1015
  $out->send();
@@ -976,14 +1021,18 @@ final class Bootstrap extends RuntimeException
976
1021
  exit;
977
1022
  }
978
1023
 
979
- if ($out !== null) {
980
- self::jsonExit($out);
981
- }
982
- exit;
1024
+ // Always emit valid JSON — the runtime parses every non-stream RPC
1025
+ // response, so a null result must arrive as the JSON literal `null`.
1026
+ self::jsonExit($out);
983
1027
  }
984
1028
 
985
- private static function jsonExit(mixed $payload): void
1029
+ private static function jsonExit(mixed $payload, int $status = 200): void
986
1030
  {
1031
+ if (!headers_sent()) {
1032
+ http_response_code($status);
1033
+ header('Content-Type: application/json; charset=UTF-8');
1034
+ }
1035
+
987
1036
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
988
1037
  exit;
989
1038
  }
@@ -991,7 +1040,18 @@ final class Bootstrap extends RuntimeException
991
1040
  private static function getRequestData(): array
992
1041
  {
993
1042
  if (!empty($_FILES)) {
994
- $data = $_POST;
1043
+ // Non-file values in an RPC multipart body are JSON-encoded by
1044
+ // the runtime (objects/arrays) or stringified scalars; decode
1045
+ // what parses so `{count: 2}` arrives as a number, not "2".
1046
+ $data = [];
1047
+ foreach ($_POST as $key => $value) {
1048
+ if (is_string($value)) {
1049
+ $decoded = json_decode($value, true);
1050
+ $data[$key] = json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
1051
+ } else {
1052
+ $data[$key] = $value;
1053
+ }
1054
+ }
995
1055
  foreach ($_FILES as $key => $file) {
996
1056
  $data[$key] = is_array($file['name'])
997
1057
  ? array_map(
@@ -1015,69 +1075,41 @@ final class Bootstrap extends RuntimeException
1015
1075
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
1016
1076
  }
1017
1077
 
1018
- private static function validateAccess(Exposed $attribute): bool
1078
+ /**
1079
+ * Authorize the caller for one exposed function, mirroring the reference
1080
+ * server: 401 when auth is required and missing, 403 on a role mismatch.
1081
+ */
1082
+ private static function authorizeAccess(Exposed $attribute): void
1019
1083
  {
1020
- if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
1021
- $auth = Auth::getInstance();
1022
-
1023
- if (!$auth->isAuthenticated()) {
1024
- return false;
1025
- }
1084
+ if (!$attribute->requiresAuth && empty($attribute->allowedRoles)) {
1085
+ return;
1086
+ }
1026
1087
 
1027
- if (!empty($attribute->allowedRoles)) {
1028
- $payload = $auth->getPayload();
1029
- $currentRole = null;
1088
+ $auth = Auth::getInstance();
1030
1089
 
1031
- if (is_scalar($payload)) {
1032
- $currentRole = $payload;
1033
- } else {
1034
- $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
1035
-
1036
- if (is_object($payload)) {
1037
- $currentRole = $payload->$roleKey ?? null;
1038
- } elseif (is_array($payload)) {
1039
- $currentRole = $payload[$roleKey] ?? null;
1040
- }
1041
- }
1042
-
1043
- if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
1044
- return false;
1045
- }
1046
- }
1090
+ if (!$auth->isAuthenticated()) {
1091
+ throw new RpcError('Authentication required', 401);
1047
1092
  }
1048
1093
 
1049
- return true;
1050
- }
1094
+ if (!empty($attribute->allowedRoles)) {
1095
+ $payload = $auth->getPayload();
1096
+ $currentRole = null;
1051
1097
 
1052
- private static function isFunctionAllowed(string $fn): bool
1053
- {
1054
- try {
1055
- $ref = new ReflectionFunction($fn);
1056
- $attrs = $ref->getAttributes(Exposed::class);
1098
+ if (is_scalar($payload)) {
1099
+ $currentRole = $payload;
1100
+ } else {
1101
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
1057
1102
 
1058
- if (empty($attrs)) {
1059
- return false;
1103
+ if (is_object($payload)) {
1104
+ $currentRole = $payload->$roleKey ?? null;
1105
+ } elseif (is_array($payload)) {
1106
+ $currentRole = $payload[$roleKey] ?? null;
1107
+ }
1060
1108
  }
1061
1109
 
1062
- return self::validateAccess($attrs[0]->newInstance());
1063
- } catch (Throwable) {
1064
- return false;
1065
- }
1066
- }
1067
-
1068
- private static function isMethodAllowed(string $class, string $method): bool
1069
- {
1070
- try {
1071
- $ref = new ReflectionMethod($class, $method);
1072
- $attrs = $ref->getAttributes(Exposed::class);
1073
-
1074
- if (empty($attrs)) {
1075
- return false;
1110
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
1111
+ throw new RpcError('Permission denied', 403);
1076
1112
  }
1077
-
1078
- return self::validateAccess($attrs[0]->newInstance());
1079
- } catch (Throwable) {
1080
- return false;
1081
1113
  }
1082
1114
  }
1083
1115
 
@@ -1110,7 +1142,33 @@ final class Bootstrap extends RuntimeException
1110
1142
  }
1111
1143
 
1112
1144
  if ($limits) {
1113
- RateLimiter::verify($identifier, $limits);
1145
+ try {
1146
+ RateLimiter::verify($identifier, $limits);
1147
+ } catch (Throwable $e) {
1148
+ throw new RpcError('Rate limit exceeded. Try again later.', 429);
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Run one exposed callable and translate its failures onto the wire:
1155
+ * `InvalidArgumentException` is a message meant for the caller (400, the
1156
+ * validation convention), anything else is a 500.
1157
+ */
1158
+ private static function invokeExposed(callable $callable, mixed $args): mixed
1159
+ {
1160
+ try {
1161
+ return call_user_func($callable, $args);
1162
+ } catch (RpcError $e) {
1163
+ throw $e;
1164
+ } catch (InvalidArgumentException $e) {
1165
+ throw new RpcError($e->getMessage(), 400);
1166
+ } catch (Throwable $e) {
1167
+ if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1168
+ throw new RpcError('Internal server error', 500);
1169
+ }
1170
+
1171
+ throw new RpcError("Function error: {$e->getMessage()}", 500);
1114
1172
  }
1115
1173
  }
1116
1174
 
@@ -1123,39 +1181,21 @@ final class Bootstrap extends RuntimeException
1123
1181
  }
1124
1182
  }
1125
1183
 
1126
- if (!self::isFunctionAllowed($fn)) {
1127
- return ['success' => false, 'error' => 'Function not callable from client'];
1184
+ if (!function_exists($fn) || !is_callable($fn)) {
1185
+ throw new RpcError('Function not found', 404);
1128
1186
  }
1129
1187
 
1130
1188
  $attribute = self::getExposedAttribute($fn);
1131
- if (!self::validateAccess($attribute)) {
1132
- return ['success' => false, 'error' => 'Permission denied'];
1189
+ if ($attribute === null) {
1190
+ // Existing but not #[Exposed]: indistinguishable from absent, on
1191
+ // purpose — the wire must not reveal which functions exist.
1192
+ throw new RpcError('Function not found', 404);
1133
1193
  }
1134
1194
 
1135
- if (function_exists($fn) && is_callable($fn)) {
1136
- try {
1137
- self::enforceRateLimit($attribute, "fn:$fn");
1138
-
1139
- $res = call_user_func($fn, $args);
1140
-
1141
- if ($res instanceof Generator || $res instanceof SSE) {
1142
- return $res;
1143
- }
1144
-
1145
- return $res;
1146
- } catch (Throwable $e) {
1147
- if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1148
- return ['success' => false, 'error' => $e->getMessage()];
1149
- }
1195
+ self::authorizeAccess($attribute);
1196
+ self::enforceRateLimit($attribute, "fn:$fn");
1150
1197
 
1151
- if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1152
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1153
- } else {
1154
- return ['success' => false, 'error' => "Function error: {$e->getMessage()}"];
1155
- }
1156
- }
1157
- }
1158
- return ['success' => false, 'error' => 'Invalid callback'];
1198
+ return self::invokeExposed($fn, $args);
1159
1199
  }
1160
1200
 
1161
1201
  private static function dispatchMethod(string $call, mixed $args)
@@ -1178,51 +1218,31 @@ final class Bootstrap extends RuntimeException
1178
1218
  }
1179
1219
 
1180
1220
  if (!class_exists($class)) {
1181
- return ['success' => false, 'error' => "Class '$requested' not found"];
1182
- }
1183
-
1184
- if (!self::isMethodAllowed($class, $method)) {
1185
- return ['success' => false, 'error' => 'Method not callable from client'];
1221
+ throw new RpcError('Function not found', 404);
1186
1222
  }
1187
1223
 
1188
1224
  $attribute = self::getExposedAttribute($class, $method);
1189
- if (!$attribute) {
1190
- return ['success' => false, 'error' => 'Method not callable from client'];
1225
+ if ($attribute === null) {
1226
+ throw new RpcError('Function not found', 404);
1191
1227
  }
1192
1228
 
1193
- if (!self::validateAccess($attribute)) {
1194
- return ['success' => false, 'error' => 'Permission denied'];
1195
- }
1229
+ self::authorizeAccess($attribute);
1230
+ self::enforceRateLimit($attribute, "method:$class::$method");
1196
1231
 
1197
- try {
1198
- self::enforceRateLimit($attribute, "method:$class::$method");
1199
-
1200
- $res = null;
1201
- if (!$isStatic) {
1202
- $instance = new $class();
1203
- if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
1204
- $res = call_user_func([$instance, $method], $args);
1205
- } else {
1206
- if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
1207
- $res = call_user_func([$class, $method], $args);
1232
+ if ($isStatic) {
1233
+ if (!is_callable([$class, $method])) {
1234
+ throw new RpcError('Function not found', 404);
1208
1235
  }
1209
1236
 
1210
- if ($res instanceof Generator || $res instanceof SSE) {
1211
- return $res;
1212
- }
1213
-
1214
- return $res;
1215
- } catch (Throwable $e) {
1216
- if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1217
- return ['success' => false, 'error' => $e->getMessage()];
1218
- }
1237
+ return self::invokeExposed([$class, $method], $args);
1238
+ }
1219
1239
 
1220
- if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1221
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1222
- } else {
1223
- return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
1224
- }
1240
+ $instance = new $class();
1241
+ if (!is_callable([$instance, $method])) {
1242
+ throw new RpcError('Function not found', 404);
1225
1243
  }
1244
+
1245
+ return self::invokeExposed([$instance, $method], $args);
1226
1246
  }
1227
1247
 
1228
1248
  private static function resolveClassImport(string $simpleClassKey): ?array
@@ -1333,7 +1353,7 @@ final class Bootstrap extends RuntimeException
1333
1353
  array_merge($currentData[$currentUrl]['includedFiles'], $srcAppFiles)
1334
1354
  ));
1335
1355
 
1336
- if (!Request::$isWire && !self::$secondRequestC69CD) {
1356
+ if (!Request::$isRpc) {
1337
1357
  $currentData[$currentUrl]['isCacheable'] = CacheHandler::$isCacheable;
1338
1358
  }
1339
1359
  } else {
@@ -1500,7 +1520,7 @@ try {
1500
1520
  }
1501
1521
 
1502
1522
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1503
- if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1523
+ if (Request::$isRpc) {
1504
1524
  if (isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1505
1525
  foreach (Bootstrap::$requestFilesData[Request::$decodedUri]['includedFiles'] as $file) {
1506
1526
  if (file_exists($file)) {
@@ -1512,14 +1532,14 @@ try {
1512
1532
  }
1513
1533
  }
1514
1534
 
1515
- if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1535
+ if (Request::$isRpc) {
1516
1536
  while (ob_get_level() > 0) {
1517
1537
  ob_end_clean();
1518
1538
  }
1519
1539
  Bootstrap::wireCallback();
1520
1540
  }
1521
1541
 
1522
- if ((!Request::$isWire && !Bootstrap::$secondRequestC69CD) && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1542
+ if (!Request::$isRpc && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1523
1543
  $cacheEnabled = (Env::string('CACHE_ENABLED', 'false') === 'true');
1524
1544
 
1525
1545
  $shouldCache = CacheHandler::$isCacheable === true
@@ -1552,22 +1572,22 @@ try {
1552
1572
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1553
1573
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1554
1574
  MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1575
+ MainLayout::$html = TemplateCompiler::deferComponentRoots(MainLayout::$html);
1555
1576
 
1556
1577
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1557
1578
 
1558
- if (!Bootstrap::$secondRequestC69CD) {
1559
- Bootstrap::createUpdateRequestData();
1560
- }
1579
+ Bootstrap::createUpdateRequestData();
1561
1580
 
1562
1581
  if (
1563
1582
  http_response_code() === 200
1564
1583
  && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1565
1584
  && $shouldCache
1566
- && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1585
+ && !Request::$isRpc
1567
1586
  ) {
1568
1587
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1569
1588
  }
1570
1589
 
1590
+ header('Content-Type: text/html; charset=UTF-8');
1571
1591
  echo MainLayout::$html;
1572
1592
  } else {
1573
1593
  $layoutPath = Bootstrap::$isContentIncluded