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