1: <?php declare(strict_types=1);
2:
3: namespace Salient\Container;
4:
5: use Salient\Cache\CacheStore;
6: use Salient\Console\Target\StreamTarget;
7: use Salient\Contract\Cache\CacheInterface;
8: use Salient\Contract\Console\ConsoleMessageType as MessageType;
9: use Salient\Contract\Container\ApplicationInterface;
10: use Salient\Contract\Core\MessageLevel as Level;
11: use Salient\Contract\Core\MessageLevelGroup as LevelGroup;
12: use Salient\Contract\Curler\Event\CurlerEventInterface;
13: use Salient\Contract\Curler\Event\CurlRequestEventInterface;
14: use Salient\Contract\Curler\Event\ResponseCacheHitEventInterface;
15: use Salient\Contract\Sync\SyncNamespaceHelperInterface;
16: use Salient\Core\Facade\Cache;
17: use Salient\Core\Facade\Config;
18: use Salient\Core\Facade\Console;
19: use Salient\Core\Facade\Err;
20: use Salient\Core\Facade\Event;
21: use Salient\Core\Facade\Profile;
22: use Salient\Core\Facade\Sync;
23: use Salient\Curler\CurlerHarRecorder;
24: use Salient\Sync\SyncStore;
25: use Salient\Utility\Exception\FilesystemErrorException;
26: use Salient\Utility\Exception\InvalidEnvironmentException;
27: use Salient\Utility\Arr;
28: use Salient\Utility\Env;
29: use Salient\Utility\File;
30: use Salient\Utility\Format;
31: use Salient\Utility\Get;
32: use Salient\Utility\Inflect;
33: use Salient\Utility\Json;
34: use Salient\Utility\Package;
35: use Salient\Utility\Regex;
36: use Salient\Utility\Sys;
37: use DateTime;
38: use DateTimeZone;
39: use LogicException;
40: use Phar;
41: use RuntimeException;
42:
43: /**
44: * A service container for applications
45: */
46: class Application extends Container implements ApplicationInterface
47: {
48: private const PATH_CACHE = 0;
49: private const PATH_CONFIG = 1;
50: private const PATH_DATA = 2;
51: private const PATH_LOG = 3;
52: private const PATH_TEMP = 4;
53: private const PARENT_CONFIG = 0;
54: private const PARENT_DATA = 1;
55: private const PARENT_STATE = 2;
56:
57: /**
58: * [ Index => [ name, parent, child, Windows child, source child ], ... ]
59: */
60: private const PATHS = [
61: self::PATH_CACHE => ['cache', self::PARENT_STATE, 'cache', 'cache', 'var/cache'],
62: self::PATH_CONFIG => ['config', self::PARENT_CONFIG, null, 'config', 'var/lib/config'],
63: self::PATH_DATA => ['data', self::PARENT_DATA, null, 'data', 'var/lib/data'],
64: self::PATH_LOG => ['log', self::PARENT_STATE, 'log', 'log', 'var/log'],
65: self::PATH_TEMP => ['temp', self::PARENT_STATE, 'tmp', 'tmp', 'var/tmp'],
66: ];
67:
68: private string $AppName;
69: private string $BasePath;
70: private string $WorkingDirectory;
71: private bool $RunningFromSource;
72:
73: /**
74: * @var array<self::PATH_*,string|null>
75: */
76: private array $Paths = [
77: self::PATH_CACHE => null,
78: self::PATH_CONFIG => null,
79: self::PATH_DATA => null,
80: self::PATH_LOG => null,
81: self::PATH_TEMP => null,
82: ];
83:
84: private bool $OutputLogIsRegistered = false;
85: private ?int $HarListenerId = null;
86: private ?CurlerHarRecorder $HarRecorder = null;
87: private string $HarFilename;
88:
89: // --
90:
91: /** @var Level::* */
92: private static int $ShutdownReportLevel;
93: private static bool $ShutdownReportResourceUsage;
94: private static bool $ShutdownReportRunningTimers;
95: /** @var string[]|string|null */
96: private static $ShutdownReportMetricGroups;
97: private static ?int $ShutdownReportMetricLimit;
98: private static bool $ShutdownReportIsRegistered = false;
99:
100: /**
101: * Creates a new Application object
102: *
103: * If `$basePath` is `null`, the value of environment variable
104: * `app_base_path` is used if present, otherwise the path of the root
105: * package is used.
106: *
107: * If `$appName` is `null`, the basename of the file used to run the script
108: * is used after removing common PHP file extensions and recognised version
109: * numbers.
110: *
111: * If `$configDir` exists and is a directory, it is passed to
112: * {@see Config::loadDirectory()} after `.env` files are loaded and values
113: * are applied from the environment to the running script.
114: *
115: * @api
116: *
117: * @param int-mask-of<Env::APPLY_*> $envFlags Values to apply from the
118: * environment to the running script.
119: * @param string|null $configDir A path relative to the application's base
120: * path, or `null` if configuration files should not be loaded.
121: */
122: public function __construct(
123: ?string $basePath = null,
124: ?string $appName = null,
125: int $envFlags = Env::APPLY_ALL,
126: ?string $configDir = 'config'
127: ) {
128: parent::__construct();
129:
130: static::setGlobalContainer($this);
131:
132: $this->AppName = $appName ?? Regex::replace(
133: '/-v?[0-9]+(\.[0-9]+){0,3}(-[0-9]+)?(-g?[0-9a-f]+)?$/i',
134: '',
135: Sys::getProgramBasename('.php', '.phar'),
136: );
137:
138: if ($basePath === null) {
139: $explicitBasePath = false;
140: $basePath = Env::get('app_base_path', null);
141: if ($basePath === null) {
142: $basePath = Package::path();
143: $defaultBasePath = true;
144: } else {
145: $defaultBasePath = false;
146: }
147: } else {
148: $explicitBasePath = true;
149: $defaultBasePath = false;
150: }
151:
152: if (!is_dir($basePath)) {
153: $exception = $explicitBasePath || $defaultBasePath
154: ? FilesystemErrorException::class
155: : InvalidEnvironmentException::class;
156: throw new $exception(sprintf('Invalid base path: %s', $basePath));
157: }
158:
159: $this->BasePath = File::realpath($basePath);
160:
161: $this->WorkingDirectory = File::getcwd();
162:
163: $this->RunningFromSource = !extension_loaded('Phar')
164: || Phar::running() === '';
165:
166: if ($this->RunningFromSource) {
167: $files = [];
168: $env = Env::getEnvironment();
169: if ($env !== null) {
170: $files[] = $this->BasePath . '/.env.' . $env;
171: }
172: $files[] = $this->BasePath . '/.env';
173: foreach ($files as $file) {
174: if (is_file($file)) {
175: Env::loadFiles($file);
176: break;
177: }
178: }
179: }
180:
181: if ($envFlags) {
182: Env::apply($envFlags);
183: }
184:
185: Console::registerStdioTargets();
186:
187: Err::register();
188:
189: $adodb = Package::getPackagePath('adodb/adodb-php');
190: if ($adodb !== null) {
191: Err::silencePath($adodb);
192: }
193:
194: if ($configDir !== null) {
195: if (!File::isAbsolute($configDir)) {
196: $configDir = $this->BasePath . '/' . $configDir;
197: }
198: if (is_dir($configDir)) {
199: Config::loadDirectory($configDir);
200: }
201: }
202: }
203:
204: /**
205: * @inheritDoc
206: */
207: public function unload(): void
208: {
209: $this->stopCache();
210: $this->stopSync();
211: if ($this->HarListenerId !== null) {
212: Event::removeListener($this->HarListenerId);
213: $this->HarListenerId = null;
214: } elseif ($this->HarRecorder) {
215: $this->HarRecorder->close();
216: $this->HarRecorder = null;
217: unset($this->HarFilename);
218: }
219: parent::unload();
220: }
221:
222: /**
223: * @inheritDoc
224: */
225: final public function getAppName(): string
226: {
227: return $this->AppName;
228: }
229:
230: /**
231: * @inheritDoc
232: */
233: public function isProduction(): bool
234: {
235: $env = Env::getEnvironment();
236:
237: return $env === 'production'
238: || ($env === null && (
239: !$this->RunningFromSource
240: || !Package::hasDevPackages()
241: ));
242: }
243:
244: /**
245: * @inheritDoc
246: */
247: final public function getBasePath(): string
248: {
249: return $this->BasePath;
250: }
251:
252: /**
253: * @inheritDoc
254: */
255: final public function getCachePath(bool $create = true): string
256: {
257: return $this->getPath(self::PATH_CACHE, $create);
258: }
259:
260: /**
261: * @inheritDoc
262: */
263: final public function getConfigPath(bool $create = true): string
264: {
265: return $this->getPath(self::PATH_CONFIG, $create);
266: }
267:
268: /**
269: * @inheritDoc
270: */
271: final public function getDataPath(bool $create = true): string
272: {
273: return $this->getPath(self::PATH_DATA, $create);
274: }
275:
276: /**
277: * @inheritDoc
278: */
279: final public function getLogPath(bool $create = true): string
280: {
281: return $this->getPath(self::PATH_LOG, $create);
282: }
283:
284: /**
285: * @inheritDoc
286: */
287: final public function getTempPath(bool $create = true): string
288: {
289: return $this->getPath(self::PATH_TEMP, $create);
290: }
291:
292: /**
293: * @param self::PATH_* $index
294: */
295: private function getPath(int $index, bool $create): string
296: {
297: if ($this->Paths[$index] !== null) {
298: return $this->Paths[$index];
299: }
300:
301: [$name, $parent, $child, $winChild, $srcChild] = self::PATHS[$index];
302: $varName = sprintf('app_%s_path', $name);
303:
304: $path = Env::get($varName, null);
305: if ($path !== null) {
306: if (trim($path) === '') {
307: throw new InvalidEnvironmentException(sprintf(
308: 'Directory disabled (empty %s in environment)',
309: $varName,
310: ));
311: }
312: if (!File::isAbsolute($path)) {
313: $path = $this->BasePath . '/' . $path;
314: }
315: } elseif (
316: !$this->isProduction()
317: && File::isCreatable($this->BasePath . '/' . $srcChild)
318: ) {
319: $path = $this->BasePath . '/' . $srcChild;
320: } elseif (Sys::isWindows()) {
321: switch ($parent) {
322: case self::PARENT_CONFIG:
323: case self::PARENT_DATA:
324: $path = Env::get('APPDATA');
325: break;
326:
327: case self::PARENT_STATE:
328: $path = Env::get('LOCALAPPDATA');
329: break;
330: }
331:
332: $path = Arr::implode('/', [$path, $this->AppName, $winChild], '');
333: } else {
334: $home = Env::getHomeDir();
335: if ($home === null || !is_dir($home)) {
336: throw new InvalidEnvironmentException('Home directory not found');
337: }
338:
339: switch ($parent) {
340: case self::PARENT_CONFIG:
341: $path = Env::get('XDG_CONFIG_HOME', $home . '/.config');
342: break;
343:
344: case self::PARENT_DATA:
345: $path = Env::get('XDG_DATA_HOME', $home . '/.local/share');
346: break;
347:
348: case self::PARENT_STATE:
349: $path = Env::get('XDG_CACHE_HOME', $home . '/.cache');
350: break;
351: }
352:
353: $path = Arr::implode('/', [$path, $this->AppName, $child], '');
354: }
355:
356: if (!File::isAbsolute($path)) {
357: // @codeCoverageIgnoreStart
358: throw new RuntimeException(sprintf(
359: 'Absolute path to %s directory required',
360: $name,
361: ));
362: // @codeCoverageIgnoreEnd
363: }
364:
365: if ($create) {
366: File::createDir($path);
367: $this->Paths[$index] = $path;
368: }
369:
370: return $path;
371: }
372:
373: /**
374: * @inheritDoc
375: */
376: final public function logOutput(?string $name = null, ?bool $debug = null)
377: {
378: if ($this->OutputLogIsRegistered) {
379: throw new LogicException('Output log already registered');
380: }
381:
382: $name ??= $this->AppName;
383: $target = StreamTarget::fromPath($this->getLogPath() . "/$name.log");
384: Console::registerTarget($target, LevelGroup::ALL_EXCEPT_DEBUG);
385:
386: if ($debug || ($debug === null && Env::getDebug())) {
387: $target = StreamTarget::fromPath($this->getLogPath() . "/$name.debug.log");
388: Console::registerTarget($target, LevelGroup::ALL);
389: }
390:
391: return $this;
392: }
393:
394: /**
395: * @inheritDoc
396: */
397: final public function exportHar(
398: ?string $name = null,
399: ?string $creatorName = null,
400: ?string $creatorVersion = null,
401: $uuid = null
402: ) {
403: if ($this->HarListenerId !== null || $this->HarRecorder) {
404: throw new LogicException('HAR recorder already started');
405: }
406:
407: $this->HarListenerId = Event::getInstance()->listen(function (
408: CurlerEventInterface $event
409: ) use ($name, $creatorName, $creatorVersion, $uuid): void {
410: if (
411: !$event instanceof CurlRequestEventInterface
412: && !$event instanceof ResponseCacheHitEventInterface
413: ) {
414: return;
415: }
416:
417: $uuid ??= fn() =>
418: Sync::isLoaded() && Sync::runHasStarted()
419: ? Sync::getRunUuid()
420: : Get::uuid();
421:
422: $filename = sprintf(
423: '%s/har/%s-%s-%s.har',
424: $this->getLogPath(),
425: $name ?? $this->AppName,
426: (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d-His.v'),
427: Get::value($uuid),
428: );
429:
430: if (file_exists($filename)) {
431: throw new RuntimeException(sprintf('File already exists: %s', $filename));
432: }
433:
434: File::createDir(dirname($filename));
435: File::create($filename, 0600);
436:
437: $recorder = new CurlerHarRecorder(
438: $filename,
439: $creatorName,
440: $creatorVersion,
441: );
442: $recorder->start($event);
443:
444: /** @var int */
445: $id = $this->HarListenerId;
446: Event::removeListener($id);
447: $this->HarListenerId = null;
448: $this->HarRecorder = $recorder;
449: $this->HarFilename = $filename;
450: });
451:
452: return $this;
453: }
454:
455: /**
456: * @phpstan-impure
457: */
458: final public function getHarFilename(): ?string
459: {
460: if ($this->HarListenerId === null && !$this->HarRecorder) {
461: throw new LogicException('HAR recorder not started');
462: }
463:
464: return $this->HarRecorder
465: ? $this->HarFilename
466: : null;
467: }
468:
469: /**
470: * @inheritDoc
471: */
472: final public function startCache()
473: {
474: if (Cache::isLoaded()) {
475: if ($this->checkCache(Cache::getInstance())) {
476: return $this;
477: }
478: throw new LogicException('Cache store already started');
479: }
480:
481: Cache::load(new CacheStore($this->getCacheDb()));
482:
483: return $this;
484: }
485:
486: /**
487: * @inheritDoc
488: */
489: final public function resumeCache()
490: {
491: return file_exists($this->getCacheDb(false))
492: ? $this->startCache()
493: : $this;
494: }
495:
496: /**
497: * @inheritDoc
498: */
499: final public function stopCache()
500: {
501: if (
502: Cache::isLoaded()
503: && $this->checkCache($cache = Cache::getInstance())
504: ) {
505: $cache->close();
506: }
507: return $this;
508: }
509:
510: private function checkCache(CacheInterface $cache): bool
511: {
512: return $cache instanceof CacheStore
513: && File::same($this->getCacheDb(false), $cache->getFilename());
514: }
515:
516: private function getCacheDb(bool $create = true): string
517: {
518: return $this->getCachePath($create) . '/cache.db';
519: }
520:
521: /**
522: * @inheritDoc
523: */
524: final public function startSync(?string $command = null, ?array $arguments = null)
525: {
526: $syncDb = $this->getSyncDb();
527:
528: if (Sync::isLoaded()) {
529: $store = Sync::getInstance();
530: if ($store instanceof SyncStore) {
531: $file = $store->getFilename();
532: if (File::same($syncDb, $file)) {
533: return $this;
534: }
535: }
536: throw new LogicException(sprintf(
537: 'Entity store already started: %s',
538: $file ?? get_class($store),
539: ));
540: }
541:
542: if ($arguments === null && $command === null) {
543: if (\PHP_SAPI === 'cli') {
544: /** @var string[] */
545: $args = $_SERVER['argv'];
546: $arguments = array_slice($args, 1);
547: } else {
548: $arguments = [Json::stringify([
549: '_GET' => $_GET,
550: '_POST' => $_POST,
551: ])];
552: }
553: }
554:
555: Sync::load(new SyncStore(
556: $syncDb,
557: $command ?? Sys::getProgramName($this->BasePath),
558: $arguments ?? [],
559: ));
560:
561: return $this;
562: }
563:
564: /**
565: * @inheritDoc
566: */
567: final public function stopSync()
568: {
569: if (
570: Sync::isLoaded()
571: && ($store = Sync::getInstance()) instanceof SyncStore
572: && File::same($this->getSyncDb(false), $store->getFilename())
573: ) {
574: $store->close();
575: }
576: return $this;
577: }
578:
579: private function getSyncDb(bool $create = true): string
580: {
581: return $this->getDataPath($create) . '/sync.db';
582: }
583:
584: /**
585: * @inheritDoc
586: */
587: final public function registerSyncNamespace(
588: string $prefix,
589: string $uri,
590: string $namespace,
591: ?SyncNamespaceHelperInterface $helper = null
592: ) {
593: if (!Sync::isLoaded()) {
594: throw new LogicException('Entity store not started');
595: }
596: Sync::registerNamespace($prefix, $uri, $namespace, $helper);
597:
598: return $this;
599: }
600:
601: /**
602: * @inheritDoc
603: */
604: final public function getWorkingDirectory(): string
605: {
606: return $this->WorkingDirectory;
607: }
608:
609: /**
610: * @inheritDoc
611: */
612: final public function restoreWorkingDirectory()
613: {
614: if (File::getcwd() !== $this->WorkingDirectory) {
615: File::chdir($this->WorkingDirectory);
616: }
617: return $this;
618: }
619:
620: /**
621: * @inheritDoc
622: */
623: final public function setWorkingDirectory(?string $directory = null)
624: {
625: $this->WorkingDirectory = $directory ?? File::getcwd();
626: return $this;
627: }
628:
629: /**
630: * @inheritDoc
631: */
632: final public function registerShutdownReport(
633: int $level = Level::INFO,
634: bool $includeResourceUsage = true,
635: bool $includeRunningTimers = true,
636: $groups = null,
637: ?int $limit = 10
638: ) {
639: self::$ShutdownReportLevel = $level;
640: self::$ShutdownReportResourceUsage = $includeResourceUsage;
641: self::$ShutdownReportRunningTimers = $includeRunningTimers;
642: self::$ShutdownReportMetricGroups = $groups;
643: self::$ShutdownReportMetricLimit = $limit;
644:
645: if (self::$ShutdownReportIsRegistered) {
646: return $this;
647: }
648:
649: register_shutdown_function(
650: static function () {
651: self::doReportMetrics(
652: self::$ShutdownReportLevel,
653: self::$ShutdownReportRunningTimers,
654: self::$ShutdownReportMetricGroups,
655: self::$ShutdownReportMetricLimit,
656: );
657: if (self::$ShutdownReportResourceUsage) {
658: self::doReportResourceUsage(self::$ShutdownReportLevel);
659: }
660: }
661: );
662:
663: self::$ShutdownReportIsRegistered = true;
664:
665: return $this;
666: }
667:
668: /**
669: * @inheritDoc
670: */
671: final public function reportResourceUsage(int $level = Level::INFO)
672: {
673: self::doReportResourceUsage($level);
674: return $this;
675: }
676:
677: /**
678: * @param Level::* $level
679: */
680: private static function doReportResourceUsage(int $level): void
681: {
682: /** @var float */
683: $requestTime = $_SERVER['REQUEST_TIME_FLOAT'];
684: [$peakMemory, $elapsedTime, $userTime, $systemTime] = [
685: memory_get_peak_usage(),
686: microtime(true) - $requestTime,
687: ...Sys::getCpuUsage(),
688: ];
689:
690: Console::print(
691: "\n" . sprintf(
692: 'CPU time: **%.3fs** elapsed, **%.3fs** user, **%.3fs** system; memory: **%s** peak',
693: $elapsedTime,
694: $userTime / 1000000,
695: $systemTime / 1000000,
696: Format::bytes($peakMemory),
697: ),
698: $level,
699: MessageType::UNFORMATTED,
700: );
701: }
702:
703: /**
704: * @inheritDoc
705: */
706: final public function reportMetrics(
707: int $level = Level::INFO,
708: bool $includeRunningTimers = true,
709: $groups = null,
710: ?int $limit = 10
711: ) {
712: self::doReportMetrics($level, $includeRunningTimers, $groups, $limit);
713: return $this;
714: }
715:
716: /**
717: * @param Level::* $level
718: * @param string[]|string|null $groups
719: */
720: private static function doReportMetrics(
721: int $level,
722: bool $includeRunningTimers,
723: $groups,
724: ?int $limit
725: ): void {
726: if ($groups !== null) {
727: $groups = (array) $groups;
728: }
729: $groupCounters = Profile::getInstance()->getCounters($groups);
730: foreach ($groupCounters as $group => $counters) {
731: // Sort by counter value, in descending order
732: uasort($counters, fn(int $a, int $b) => $b <=> $a);
733:
734: $maxValue = $totalValue = 0;
735: $count = count($counters);
736: foreach ($counters as $value) {
737: $totalValue += $value;
738: $maxValue = max($maxValue, $value);
739: }
740:
741: if ($limit !== null && $limit < $count) {
742: $counters = array_slice($counters, 0, $limit, true);
743: }
744:
745: $lines = [];
746: $lines[] = Inflect::format(
747: $totalValue,
748: "**{{#}}** {{#:event}} recorded by %s in group '**%s**':",
749: Inflect::format($count, '**{{#}}** {{#:counter}}'),
750: $group,
751: );
752:
753: $valueWidth = strlen((string) $maxValue);
754: $format = " %{$valueWidth}d ***%s***";
755: foreach ($counters as $name => $value) {
756: $lines[] = sprintf($format, $value, $name);
757: }
758:
759: if ($hidden = $count - count($counters)) {
760: $width = $valueWidth + 3;
761: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
762: }
763:
764: $report[] = implode("\n", $lines);
765: }
766:
767: $groupTimers = Profile::getInstance()->getTimers($includeRunningTimers, $groups);
768: foreach ($groupTimers as $group => $timers) {
769: // Sort by milliseconds elapsed, in descending order
770: uasort($timers, fn(array $a, array $b) => $b[0] <=> $a[0]);
771:
772: $maxRuns = $maxTime = $totalTime = 0;
773: $count = count($timers);
774: foreach ($timers as [$time, $runs]) {
775: $totalTime += $time;
776: $maxTime = max($maxTime, $time);
777: $maxRuns = max($maxRuns, $runs);
778: }
779:
780: if ($limit !== null && $limit < $count) {
781: $timers = array_slice($timers, 0, $limit, true);
782: }
783:
784: $lines = [];
785: $lines[] = Inflect::format(
786: $count,
787: "**%.3fms** recorded by **{{#}}** {{#:timer}} in group '**%s**':",
788: $totalTime,
789: $group,
790: );
791:
792: $timeWidth = strlen((string) (int) $maxTime) + 4;
793: $runsWidth = strlen((string) $maxRuns) + 2;
794: $format = " %{$timeWidth}.3fms ~~{~~%{$runsWidth}s~~}~~ ***%s***";
795: foreach ($timers as $name => [$time, $runs]) {
796: $lines[] = sprintf($format, $time, sprintf('*%d*', $runs), $name);
797: }
798:
799: if ($hidden = $count - count($timers)) {
800: $width = $timeWidth + $runsWidth + 6;
801: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
802: }
803:
804: $report[] = implode("\n", $lines);
805: }
806:
807: if (isset($report)) {
808: Console::print(
809: "\n" . implode("\n\n", $report),
810: $level,
811: MessageType::UNFORMATTED,
812: );
813: }
814: }
815: }
816: