Files
kupshop/bundles/KupShop/FeedGeneratorBundle/V8FeedGenerator.php
2025-08-02 16:30:27 +02:00

318 lines
13 KiB
PHP

<?php
namespace KupShop\FeedGeneratorBundle;
use KupShop\FeedGeneratorBundle\Configuration\Configuration;
use KupShop\FeedGeneratorBundle\Configuration\V8BuildingVisitor;
use KupShop\FeedGeneratorBundle\Expressions\IExpressionObject;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
class V8FeedGenerator
{
/** @var Configuration */
protected $configuration;
/** @var Serializer */
protected $serializer;
protected \V8Js $v8js;
protected string $js;
/** @var V8BuildingVisitor */
protected $jsBuilder;
/** @var IExpressionObject */
protected $wrapper;
/** @var ContextPropertiesLocator */
protected $contextPropertiesLocator;
/** @var bool */
protected $throwErrors = false;
protected bool $isPreview = false;
/** @var callable */
protected $buildItemCallback;
protected $indent = false;
protected $report = [];
public function __construct(
SerializerInterface $serializer,
V8BuildingVisitor $jsBuilder,
ContextPropertiesLocator $contextPropertiesLocator,
) {
$this->serializer = $serializer;
$this->jsBuilder = $jsBuilder;
$this->contextPropertiesLocator = $contextPropertiesLocator;
}
public function setIndent(bool $indent): self
{
$this->indent = $indent;
return $this;
}
public function setThrowErrors(bool $throwErrors = true): void
{
$this->throwErrors = $throwErrors;
}
public function setIsPreview(bool $isPreview = true): void
{
$this->isPreview = $isPreview;
}
public function setWrapper(IExpressionObject $wrapper): self
{
$this->wrapper = $wrapper;
return $this;
}
public function setBuildItemCallback(?callable $callback = null): void
{
$this->buildItemCallback = $callback;
}
public function getConfiguration(): Configuration
{
return $this->configuration;
}
public function setConfiguration(?string $jsonConfiguration)
{
// deserialize configuration from JSON string to Configuration object
$this->configuration = $configuration = $this->serializer->deserialize($jsonConfiguration ?? '{}', Configuration::class, 'json');
$this->jsBuilder->setIndent($this->indent);
$this->jsBuilder->initialize($this->wrapper);
$this->configuration->accept($this->jsBuilder);
$this->js = $js = $this->jsBuilder->getOutput();
// enable array access for MultiWrapper and ObjectMultiWrapper
ini_set('v8js.use_array_access', true);
// convert PHP DateTime to js Date
ini_set('v8js.use_date', true);
$properties = [];
$methods = [];
foreach ($this->wrapper->getRawFieldsAndMethods() as $item) {
if ($item['method']) {
$methods[$item['name']] = '';
} else {
$properties[$item['name']] = '';
}
}
$this->v8js = $v8js = new \V8Js(null, $properties);
$v8js->__isPreview = $this->isPreview;
$v8js->__properties = $properties;
$v8js->__methods = $methods;
$v8js->__buildItemCallback = $this->buildItemCallback;
// add common context properties
$contextPropertiesNames = array_keys($this->contextPropertiesLocator->getContexts());
$v8js->__contextProperties = array_combine(
$contextPropertiesNames,
array_fill(0, count($contextPropertiesNames), null)
);
$v8js->__getContextPropertyService = function ($name) {
return $this->contextPropertiesLocator->getServiceByContextName($name);
};
// add source expression to error message for report
$v8js->__transformJSError = function ($exception, $message, $stack) {
if (!empty($message)) {
// probably js error: has filled message
return $this->transformExceptionOrStackIntoMesssage(null, $message, $stack);
} elseif ($exception instanceof \Throwable) {
return $exception->getMessage();
} else {
return $exception;
}
};
try {
if (getVal('v8debug')) {
print_r($js.PHP_EOL.'print(__buildXML());'.PHP_EOL);
exit;
}
$v8js->executeString(file_get_contents(__DIR__.'/Expressions/v8feed.XMLWriter.js'), 'XMLWriter');
$v8js->executeString(file_get_contents(__DIR__.'/Expressions/v8feed.dayjs.min.js'), 'V8Functions');
$v8js->executeString(file_get_contents(__DIR__.'/Expressions/v8feed.functions.js'), 'V8Functions');
$v8js->executeString($js, 'configuration');
} catch (\V8JsScriptException $e) {
throw $this->transformException($e);
}
}
/**
* @throws \Throwable
*/
public function transform(\Generator $items): void
{
$this->transformInternal($items, function (): void {
// __runBuild is set to true, so whole feed will be rendered using `v8feed.transform.js`
$this->v8js->__runBuild = true;
try {
$this->v8js->executeString(
file_get_contents(__DIR__.'/Expressions/v8feed.transform.js'),
'',
\V8Js::FLAG_PROPAGATE_PHP_EXCEPTIONS
);
} catch (\V8JsScriptException $e) {
throw $this->transformException($e);
}
});
}
public function transformWithGenerator(\Generator $items): \Generator
{
return $this->transformInternal($items, function (): \Generator {
try {
// just include code so we can use it
$this->v8js->executeString(
file_get_contents(__DIR__.'/Expressions/v8feed.transform.js'),
'',
\V8Js::FLAG_PROPAGATE_PHP_EXCEPTIONS
);
// render feed using `__buildBatch` so we can yield it from generator
while (true) {
ob_start();
$done = $this->v8js->executeString('__buildBatch(100);', '', \V8Js::FLAG_PROPAGATE_PHP_EXCEPTIONS);
$xml = ob_get_contents();
ob_end_clean();
if (!empty($xml)) {
yield $xml;
}
if ($done) {
break;
}
}
$this->v8js->executeString('__updateReport();');
} catch (\V8JsScriptException $e) {
throw $this->transformException($e);
}
});
}
public function getReport(): array
{
return $this->report;
}
protected function transformInternal(\Generator $items, callable $fn): mixed
{
$time_start = getScriptTime();
$this->v8js->__updateReport = function ($newReport) use ($time_start) {
$report = (array) $newReport;
$report['errors'] = (array) $report['errors']; // V8 passes it as stdClass, but we expect PHP's associative array
$report['reportCounters'] = (array) $report['reportCounters']; // V8 passes it as stdClass, but we expect PHP's associative array
$report['time_generate'] = getScriptTime() - $time_start;
$report['peak_memory'] = memory_get_peak_usage(true); // in bytes
$this->report = $report;
};
$this->v8js->__throwErrors = (bool) $this->throwErrors;
$this->v8js->__isDevelopment = (bool) isDevelopment();
$this->v8js->__contextWrapper = $this->wrapper;
$this->v8js->__getWrapperProp = function ($prop) {
return $this->wrapper[$prop];
};
$this->v8js->__items = $items;
return $fn();
}
protected function transformException(\Exception $exception): \Exception
{
if ($exception instanceof \V8JsScriptException) {
$exception = new V8ErrorException($this->transformExceptionOrStackIntoMesssage($exception), 0, $exception);
}
return $exception;
}
protected function transformExceptionOrStackIntoMesssage(?\V8JsScriptException $exception = null, ?string $description = null, ?string $stack = null): string
{
// when V8Js::compileString() error appear after __buildXML() is called, the exception doesn't contain relevant source line
// this finds the proper source line if possible
// (.*)\s+at configuration:(\d+):(\d*)\s+at __buildXML \(configuration:\d*:\d*\)\s*at <anonymous>:\d*:\d*(?:\s*in: throw error;)?
// (.*)\s+[^\n]*\s+at __buildXML \(configuration:(\d+):(\d*)\)\s*at <anonymous>:\d*:\d*(?:\s*in: throw error;)?
// (.*)\s+at configuration:(\d+):(\d*)\s+at __buildXML \(configuration:\d*:\d*\)\s*at V8Js::compileString\(\):\d*:\d*(?:\s*in: throw error;)?
// (.*)\s+[^\n]*\s+at __buildXML \(configuration:(\d+):(\d*)\)\s*at V8Js::compileString\(\):\d*:\d*(?:\s*in: throw error;)?
if (preg_match('/(.*)\\s+at configuration:(\\d+):(\\d*)\\s+at __buildXML \\(configuration:\\d*:\\d*\\)\\s*at <anonymous>:\\d*:\\d*(?:\\s*in: throw error;)?/', $exception ? $exception->getJsTrace() : $stack, $matches)
|| preg_match('/(.*)\\s+[^\\n]*\\s+at __buildXML \\(configuration:(\\d+):(\\d*)\\)\\s*at <anonymous>:\\d*:\\d*(?:\\s*in: throw error;)?/', $stack, $matches)
|| preg_match('/(.*)\\s+at configuration:(\\d+):(\\d*)\\s+at __buildXML \\(configuration:\\d*:\\d*\\)\\s*at V8Js::compileString\\(\\):\\d*:\\d*(?:\\s*in: throw error;)?/', $exception ? $exception->getJsTrace() : $stack, $matches)
|| preg_match('/(.*)\\s+[^\\n]*\\s+at __buildXML \\(configuration:(\\d+):(\\d*)\\)\\s*at V8Js::compileString\\(\\):\\d*:\\d*(?:\\s*in: throw error;)?/', $stack, $matches)
) {
$lineNumber = $matches[2] - 1; // rows will be indexed from 0
// explode built js configuration
$tmp = explode(PHP_EOL, $this->js.PHP_EOL);
$rawExpression = trim($tmp[$lineNumber] ?? '');
$errorDescription = "{$matches[1]} on line {$lineNumber}:{$matches[3]}";
} elseif ($exception instanceof \V8JsScriptException) {
// otherwise get source line directly from the exception
$rawExpression = $exception ? $exception->getJsSourceLine() : $stack;
$lineNumber = $exception->getJsLineNumber();
$rawExpression .= PHP_EOL.' ['.$exception->getJsLineNumber().', '.$exception->getJsStartColumn().']';
$errorDescription = $exception ? $exception->getJsTrace() : $description;
if ($exception->getJsFileName() === 'undefined') {
// for ExternalFeed error when source file has no root element or other failures during read
$lines = explode(PHP_EOL, $exception->getMessage());
$mainMsg = str_replace('/vagrant/home/engine/bundles/KupShop', '...', $lines[0]);
$mainMsg = str_replace('/vagrant/home', '...', $mainMsg);
$errorDescription = $mainMsg.';;;'.PHP_EOL.' '.$errorDescription;
if (mb_strpos($mainMsg, 'XMLReader::read()')) {
$errorDescription = 'Failed to read source XML file!'.PHP_EOL.' '.$errorDescription;
}
}
} else {
return $description;
}
// try to extract exact original expression and block type
$regexesForExtraction = [
'/if \\(\\(function\\(\\){\\s+return (.*);\\s+}\\)\\(\\)\\) {/' => ' Condition', // if \(\(function\(\){\s+return (.*);\s+}\)\(\)\) {
'/__xmlWriter\\.text\\(\\(function\\(\\){\\s+return (.*);\\s+}\\)\\(\\)\\);/' => ' Expression', // __xmlWriter\.text\(\(function\(\){\s+return (.*);\s+}\)\(\)\);
'/for \\(const \\[.*?\\] of Object\\.entries\\(\\(function\\(\\){\\s+return (.*);\\s+}\\)\\(\\)\\)\\) {/' => ' Cycle expression', // for \(const \[.*?\] of Object\.entries\(\(function\(\){\s+return (.*);\s+}\)\(\)\)\) {
];
$extractedExpression = null;
$blockName = '';
if ($lineNumber > 1) {
foreach ($regexesForExtraction as $regex => $tmpBlockName) {
if (preg_match($regex, ($tmp[$lineNumber - 1] ?? '').($tmp[$lineNumber] ?? '').($tmp[$lineNumber + 1] ?? ''), $matches)) {
$extractedExpression = $matches[1] ?? '';
$blockName = $tmpBlockName;
break;
}
}
if ($blockName === '') {
// try Multiline
// original regex __xmlWriter\.text\(\(function\(\){\s+[\S\s]*
if (preg_match('/__xmlWriter\\.text\\(\\(function\\(\\){\\s+[\\S\\s]*/', ($tmp[$lineNumber - 1] ?? '').($tmp[$lineNumber] ?? ''))) {
$blockName = ' Multiline';
}
}
}
return $errorDescription.' in'.$blockName.': '.($extractedExpression ?? $rawExpression);
}
}