From 8d04bf843f8e60be9acf1d3fe3cf3213ad991a37 Mon Sep 17 00:00:00 2001 From: jeefleyd Date: Sun, 12 Apr 2026 00:51:40 -0400 Subject: [PATCH] feat: add Drupal 9/10/11 HTTP request handler, method executor, and task runner for DDLess --- src/frameworks/drupal/http_request.php | 600 ++++++++++++++++++++++ src/frameworks/drupal/method_executor.php | 209 ++++++++ src/frameworks/drupal/task_runner.php | 255 +++++++++ 3 files changed, 1064 insertions(+) create mode 100644 src/frameworks/drupal/http_request.php create mode 100644 src/frameworks/drupal/method_executor.php create mode 100644 src/frameworks/drupal/task_runner.php diff --git a/src/frameworks/drupal/http_request.php b/src/frameworks/drupal/http_request.php new file mode 100644 index 0000000..a15f8ea --- /dev/null +++ b/src/frameworks/drupal/http_request.php @@ -0,0 +1,600 @@ +position = 0; + $this->isInput = ($path === 'php://input'); + $this->buffer = ''; + return true; + } + + public function stream_read($count) + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + $data = substr($dataSource, $this->position, $count); + $this->position += strlen($data); + return $data; + } + + public function stream_eof() + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + return $this->position >= strlen($dataSource); + } + + public function stream_tell() { return $this->position; } + + public function stream_seek($offset, $whence = SEEK_SET) + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + $length = strlen($dataSource); + switch ($whence) { + case SEEK_SET: $target = $offset; break; + case SEEK_CUR: $target = $this->position + $offset; break; + case SEEK_END: $target = $length + $offset; break; + default: return false; + } + if ($target < 0) return false; + $this->position = $target; + return true; + } + + public function stream_stat() + { + $size = $this->isInput ? strlen($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : strlen($this->buffer); + return ['size' => $size]; + } + + public function stream_write($data) + { + if ($this->isInput) return strlen($data); + $len = strlen($data); + $before = substr($this->buffer, 0, $this->position); + $after = substr($this->buffer, $this->position + $len); + $this->buffer = $before . $data . $after; + $this->position += $len; + return $len; + } + + public function stream_truncate(int $newSize) + { + if ($this->isInput) return false; + if ($newSize < strlen($this->buffer)) { + $this->buffer = substr($this->buffer, 0, $newSize); + } else { + $this->buffer = str_pad($this->buffer, $newSize, "\0"); + } + if ($this->position > $newSize) $this->position = $newSize; + return true; + } + } +} + +function ddless_normalize_relative_path(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $root = defined('DDLESS_PROJECT_ROOT') ? str_replace('\\', '/', (string)DDLESS_PROJECT_ROOT) : null; + if ($root && str_starts_with($normalized, $root)) { + return ltrim(substr($normalized, strlen($root)), '/'); + } + return ltrim($normalized, '/'); +} + +function ddless_normalize_headers(array $headers): array +{ + $normalized = []; + foreach ($headers as $key => $value) { + if (is_array($value)) { + $normalized[$key] = array_map( + static fn($entry) => is_scalar($entry) ? (string)$entry : json_encode($entry), + $value, + ); + } elseif ($value !== null) { + $normalized[$key] = (string)$value; + } + } + return $normalized; +} + +if (!function_exists('ddless_normalize_value')) { + function ddless_normalize_value($value, int $depth = 0) + { + $maxDepth = $GLOBALS['__DDLESS_SERIALIZE_DEPTH__'] ?? 4; + if ($depth > $maxDepth) { + if (is_object($value)) return '[object ' . get_class($value) . ']'; + return is_scalar($value) ? $value : '[max-depth]'; + } + if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) return $value; + if (is_string($value)) { + return strlen($value) > 10000 ? substr($value, 0, 10000) . "\xe2\x80\xa6" : $value; + } + if (is_array($value)) { + $result = []; + foreach ($value as $key => $item) { $result[$key] = ddless_normalize_value($item, $depth + 1); } + return $result; + } + if ($value instanceof \JsonSerializable) { + try { return ddless_normalize_value($value->jsonSerialize(), $depth + 1); } + catch (\Throwable $e) { return '[object ' . get_class($value) . ']'; } + } + if (method_exists($value, 'toArray')) { + try { return ddless_normalize_value($value->toArray(), $depth + 1); } + catch (\Throwable $e) {} + } + if ($value instanceof \DateTimeInterface) return $value->format(\DateTimeInterface::ATOM); + if (is_object($value)) return '[object ' . get_class($value) . ']'; + if (is_resource($value)) return '[resource ' . (get_resource_type($value) ?: 'resource') . ']'; + return (string)$value; + } +} + +function ddless_read_breakpoints(string $ddlessDir): array +{ + $path = $ddlessDir . DIRECTORY_SEPARATOR . 'breakpoints.json'; + if (!is_file($path)) return []; + try { + $raw = file_get_contents($path); + if ($raw === false) return []; + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) return []; + $result = []; + foreach ($decoded as $file => $lines) { + if (!is_array($lines)) continue; + $normalizedFile = ddless_normalize_relative_path((string)$file); + $normalizedLines = []; + foreach ($lines as $line) { if (is_numeric($line)) $normalizedLines[] = (int)$line; } + if ($normalizedFile !== '' && $normalizedLines) { + $result[$normalizedFile] = array_values(array_unique($normalizedLines)); + } + } + return $result; + } catch (\Throwable $e) { + fwrite(STDERR, "[ddless] Failed to read breakpoints: {$e->getMessage()}\n"); + return []; + } +} + +function ddless_collect_logs(string $logDir): array +{ + // Drupal uses dblog by default (database), but syslog module writes to system log. + // Check for any file-based logs in the configured directory. + $candidates = ['drupal.log', 'error.log', 'php-error.log']; + foreach ($candidates as $candidate) { + $path = $logDir . DIRECTORY_SEPARATOR . $candidate; + if (is_file($path) && is_readable($path)) { + $handle = fopen($path, 'rb'); + if ($handle === false) continue; + $newSize = filesize($path); + if ($newSize > 32768) { fseek($handle, $newSize - 32768); fgets($handle); } + $data = trim(stream_get_contents($handle) ?: ''); + fclose($handle); + if ($data !== '') { + $lines = preg_split("/\r?\n/", $data); + return $lines ? array_slice(array_map('strval', $lines), -200) : []; + } + } + } + return []; +} + +function ddless_encode_json($value): string +{ + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return $encoded === false ? "{}" : $encoded; +} + +function ddless_prepare_body_payload(?string $content, ?string $contentType): array +{ + $raw = $content ?? ''; + $encoding = 'utf8'; + $truncated = false; + $isTextual = $contentType === null || preg_match('/json|text|xml|javascript|css|html|csv|urlencoded/i', $contentType); + if (!$isTextual) { + $encoding = 'base64'; + $prepared = base64_encode($raw); + } else { + if ($raw !== '' && function_exists('mb_check_encoding') && !mb_check_encoding($raw, 'UTF-8')) { + $encoding = 'base64'; + $prepared = base64_encode($raw); + } else { + if (strlen($raw) > 131072) { $prepared = substr($raw, 0, 131072); $truncated = true; } + else { $prepared = $raw; } + } + } + return ['content' => $prepared, 'encoding' => $encoding, 'truncated' => $truncated]; +} + +function ddless_response_status_text(int $statusCode): ?string +{ + static $map = [ + 100 => 'Continue', 101 => 'Switching Protocols', + 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 204 => 'No Content', + 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', + 307 => 'Temporary Redirect', 308 => 'Permanent Redirect', + 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', + 405 => 'Method Not Allowed', 409 => 'Conflict', 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', + ]; + return $map[$statusCode] ?? null; +} + +// Drupal Bootstrap +$projectRoot = !empty($GLOBALS['__DDLESS_PLAYGROUND__']) && defined('DDLESS_PROJECT_ROOT') + ? DDLESS_PROJECT_ROOT + : dirname(__DIR__, 3); +$ddlessDirectory = dirname(__DIR__, 2); + +if (!defined('DDLESS_PROJECT_ROOT')) { + define('DDLESS_PROJECT_ROOT', $projectRoot); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Proxy mode (php -S / cli-server): include index.php directly. +// Drupal's entry point handles bootstrap, routing, and response natively. +// ═══════════════════════════════════════════════════════════════════════════════ + +if (php_sapi_name() === 'cli-server') { + register_shutdown_function( + function () use ($projectRoot, $ddlessDirectory) { + $statusCode = http_response_code() ?: 200; + $responseHeaders = []; + foreach (headers_list() as $headerLine) { + $colonPos = strpos($headerLine, ':'); + if ($colonPos !== false) { + $responseHeaders[trim(substr($headerLine, 0, $colonPos))] = trim(substr($headerLine, $colonPos + 1)); + } + } + $snapshot = [ + 'timestamp' => date('c'), + 'sessionId' => null, + 'request' => [ + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'path' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/', + 'fullUrl' => ($_SERVER['REQUEST_SCHEME'] ?? 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ($_SERVER['REQUEST_URI'] ?? '/'), + 'headers' => [], 'query' => $_GET ?? [], 'input' => $_POST ?? [], 'cookies' => $_COOKIE ?? [], + ], + 'response' => [ + 'status' => $statusCode, 'statusText' => null, + 'headers' => $responseHeaders, + 'body' => ['content' => '', 'encoding' => 'utf8', 'truncated' => false], + ], + 'context' => [ + 'route' => null, 'controller' => null, + 'breakpoints' => [], 'hitBreakpoints' => [], + 'variables' => [], 'callStack' => [], + ], + 'metrics' => ['durationMs' => 0, 'memoryPeakBytes' => memory_get_peak_usage(true)], + 'logs' => [], + ]; + $encoded = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + if (function_exists('ddless_get_session_dir')) { + @file_put_contents(ddless_get_session_dir() . DIRECTORY_SEPARATOR . 'last_execution.json', $encoded); + } + @file_put_contents($ddlessDirectory . DIRECTORY_SEPARATOR . 'last_execution.json', $encoded); + fwrite(STDERR, "__DDLESS_REQUEST_COMPLETE__\n"); + } + ); + + if (getenv('DDLESS_DEBUG_MODE') === 'true') { + $debugModule = dirname(__DIR__, 2) . '/debug.php'; + if (file_exists($debugModule)) { + require_once $debugModule; + if (function_exists('ddless_register_stream_wrapper')) { + ddless_register_stream_wrapper(); + } + } + } + + fwrite(STDERR, "[ddless] Starting Drupal request handling...\n"); + + // Composer-based Drupal: web/index.php. Legacy: index.php at root. + $webDir = $projectRoot . '/web'; + if (is_file($webDir . '/index.php')) { + $indexFile = $webDir . '/index.php'; + chdir($webDir); + } elseif (is_file($projectRoot . '/index.php')) { + $indexFile = $projectRoot . '/index.php'; + chdir($projectRoot); + } else { + http_response_code(500); + echo "DDLess: Drupal index.php not found (checked web/index.php and index.php)."; + exit(1); + } + + require $indexFile; + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CLI mode (DDLess direct request): DrupalKernel bootstrap, handle, snapshot. +// ═══════════════════════════════════════════════════════════════════════════════ + +$breakpoints = ddless_read_breakpoints($ddlessDirectory); +$executionStartedAt = microtime(true); + +// Composer-based Drupal: web/ is the docroot, autoload.php is at root. +$webDir = is_dir($projectRoot . '/web') ? $projectRoot . '/web' : $projectRoot; +chdir($webDir); + +$autoloadFile = $projectRoot . '/autoload.php'; +if (!is_file($autoloadFile)) { + $autoloadFile = $projectRoot . '/vendor/autoload.php'; +} +$autoloader = require_once $autoloadFile; + +if (!class_exists('Drupal\Core\DrupalKernel')) { + fwrite(STDERR, "[ddless] DrupalKernel not found. Run composer install.\n"); + exit(1); +} + +$logDir = $projectRoot . DIRECTORY_SEPARATOR . 'sites' . DIRECTORY_SEPARATOR . 'default' . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . 'logs'; + +$phpWrapperReplaced = false; +if (in_array('php', stream_get_wrappers(), true)) { + $phpWrapperReplaced = @stream_wrapper_unregister('php'); + if ($phpWrapperReplaced) { + if (!stream_wrapper_register('php', 'DDLessPhpInputStream')) { + stream_wrapper_restore('php'); + $phpWrapperReplaced = false; + } + } +} + +if (getenv('DDLESS_DEBUG_MODE') === 'true') { + if (function_exists('ddless_register_stream_wrapper')) { + ddless_register_stream_wrapper(); + } +} + +// Pre-load instrumented files through the wrapper +if (getenv('DDLESS_DEBUG_MODE') === 'true' && !empty($GLOBALS['__DDLESS_INSTRUMENTED_CODE__'])) { + foreach (array_keys($GLOBALS['__DDLESS_INSTRUMENTED_CODE__']) as $instrumentedPath) { + if (is_file($instrumentedPath) && str_ends_with($instrumentedPath, '.php')) { + include_once $instrumentedPath; + } + } +} + +try { + fwrite(STDERR, "[ddless] Starting Drupal request handling...\n"); + + $request = \Symfony\Component\HttpFoundation\Request::createFromGlobals(); + $kernel = \Drupal\Core\DrupalKernel::createFromRequest($request, $autoloader, 'prod'); + $kernel->boot(); + + $response = $kernel->handle($request); + + $statusCode = $response->getStatusCode(); + $responseContentType = $response->headers->get('Content-Type'); + + ob_start(); + $response->sendContent(); + $capturedBodyContent = ob_get_clean(); + + if ($capturedBodyContent === '' || $capturedBodyContent === null) { + $capturedBodyContent = $response->getContent() ?: ''; + } + + $statusText = ddless_response_status_text($statusCode) ?? 'OK'; + + echo sprintf("HTTP/1.1 %d %s\r\n", $statusCode, $statusText); + foreach ($response->headers->allPreserveCase() as $name => $values) { + if (!is_array($values)) $values = [$values]; + foreach ($values as $value) { + echo sprintf("%s: %s\r\n", $name, $value); + } + } + echo "\r\n"; + echo $capturedBodyContent; + + fwrite(STDERR, "[ddless] Request handling completed, status: " . $statusCode . "\n"); + + // Snapshot Generation + $executionFinishedAt = microtime(true); + $durationMs = ($executionFinishedAt - $executionStartedAt) * 1000; + + $rawInput = $GLOBALS['__DDLESS_RAW_INPUT__'] ?? ''; + $contentType = $_SERVER['CONTENT_TYPE'] ?? null; + + $normalizedRequestHeaders = ddless_normalize_headers($request->headers->all() ?? []); + $requestSummary = [ + 'method' => $request->getMethod(), + 'path' => $request->getPathInfo(), + 'fullUrl' => $request->getUri(), + 'headers' => $normalizedRequestHeaders, + 'query' => ddless_normalize_value($request->query->all()), + 'input' => ddless_normalize_value($request->request->all()), + 'cookies' => ddless_normalize_value($request->cookies->all()), + 'rawBody' => ddless_prepare_body_payload($rawInput, $contentType), + ]; + + $snapshotResponseHeaders = ddless_normalize_headers($response->headers->allPreserveCase()); + $responseBodyPayload = ddless_prepare_body_payload( + is_string($capturedBodyContent) ? $capturedBodyContent : '', + is_string($responseContentType) ? $responseContentType : null, + ); + + $routeInfo = null; + $controllerInfo = null; + $hitBreakpoints = []; + $callStack = []; + + // Extract route/controller from Drupal's routing system + try { + $routeMatch = $request->attributes->get('_route_object'); + $controllerAttr = $request->attributes->get('_controller'); + $routeName = $request->attributes->get('_route'); + + if ($controllerAttr !== null) { + $routeInfo = [ + 'uri' => $requestSummary['path'], + 'name' => $routeName, + 'methods' => [$request->getMethod()], + 'action' => is_string($controllerAttr) ? $controllerAttr : (is_array($controllerAttr) ? implode('::', $controllerAttr) : ''), + 'parameters' => ddless_normalize_value($request->attributes->get('_raw_variables', [])), + ]; + + $controllerClass = null; + $controllerMethod = null; + + if (is_string($controllerAttr)) { + if (strpos($controllerAttr, '::') !== false) { + [$controllerClass, $controllerMethod] = explode('::', $controllerAttr, 2); + } elseif (strpos($controllerAttr, ':') !== false) { + [$service, $controllerMethod] = explode(':', $controllerAttr, 2); + try { + $serviceObj = \Drupal::service($service); + $controllerClass = get_class($serviceObj); + } catch (\Throwable $e) {} + } + } + + if ($controllerClass && $controllerMethod && class_exists($controllerClass) && method_exists($controllerClass, $controllerMethod)) { + $reflection = new \ReflectionMethod($controllerClass, $controllerMethod); + $controllerFile = $reflection->getFileName() ?: null; + $startLine = $reflection->getStartLine() ?: null; + $endLine = $reflection->getEndLine() ?: null; + + if ($controllerFile) { + $relativeControllerPath = ddless_normalize_relative_path( + str_starts_with($controllerFile, $projectRoot) + ? substr($controllerFile, strlen($projectRoot) + 1) + : $controllerFile + ); + + if ($relativeControllerPath && isset($breakpoints[$relativeControllerPath])) { + foreach ($breakpoints[$relativeControllerPath] as $line) { + if ($startLine !== null && $endLine !== null && $line >= $startLine && $line <= $endLine) { + $hitBreakpoints[$relativeControllerPath][] = $line; + } + } + } + + $controllerInfo = [ + 'file' => $relativeControllerPath, + 'class' => $controllerClass, + 'method' => $controllerMethod, + 'startLine' => $startLine, + 'endLine' => $endLine, + ]; + + if (!empty($hitBreakpoints[$relativeControllerPath])) { + sort($hitBreakpoints[$relativeControllerPath]); + } + } + + $callStack[] = [ + 'label' => $routeInfo['action'], + 'file' => $controllerInfo['file'] ?? null, + 'line' => $controllerInfo['startLine'] ?? null, + ]; + } + } + } catch (\Throwable $e) {} + + $variables = [ + 'routeParameters' => $routeInfo['parameters'] ?? [], + 'requestInput' => $requestSummary['input'], + 'query' => $requestSummary['query'], + ]; + + // Extract current user + try { + $currentUser = \Drupal::currentUser(); + if ($currentUser && $currentUser->isAuthenticated()) { + $variables['user'] = ddless_normalize_value([ + 'uid' => $currentUser->id(), + 'name' => $currentUser->getAccountName(), + 'email' => $currentUser->getEmail(), + 'roles' => $currentUser->getRoles(), + ]); + } + } catch (\Throwable $e) {} + + // Extract session + try { + if ($request->hasSession()) { + $session = $request->getSession(); + $variables['session'] = ddless_normalize_value($session->all()); + } + } catch (\Throwable $e) {} + + $logs = ddless_collect_logs($logDir); + + $snapshot = [ + 'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + 'sessionId' => null, + 'request' => $requestSummary, + 'response' => [ + 'status' => $statusCode, + 'statusText' => ddless_response_status_text($statusCode), + 'headers' => ddless_normalize_headers($snapshotResponseHeaders), + 'body' => $responseBodyPayload, + ], + 'context' => [ + 'route' => $routeInfo, + 'controller' => $controllerInfo, + 'breakpoints' => $breakpoints, + 'hitBreakpoints' => $hitBreakpoints, + 'variables' => $variables, + 'callStack' => $callStack, + ], + 'metrics' => [ + 'durationMs' => round($durationMs, 4), + 'memoryPeakBytes' => memory_get_peak_usage(true), + ], + 'logs' => $logs, + ]; + + $encodedSnapshot = ddless_encode_json($snapshot) . "\n"; + if (function_exists('ddless_get_session_dir')) { + @file_put_contents(ddless_get_session_dir() . DIRECTORY_SEPARATOR . 'last_execution.json', $encodedSnapshot); + } + @file_put_contents($ddlessDirectory . DIRECTORY_SEPARATOR . 'last_execution.json', $encodedSnapshot); + + $kernel->terminate($request, $response); + fwrite(STDERR, "[ddless] Request completed successfully\n"); +} finally { + if ($phpWrapperReplaced) { + @stream_wrapper_restore('php'); + } + fwrite(STDERR, "__DDLESS_REQUEST_COMPLETE__\n"); +} diff --git a/src/frameworks/drupal/method_executor.php b/src/frameworks/drupal/method_executor.php new file mode 100644 index 0000000..76b96a9 --- /dev/null +++ b/src/frameworks/drupal/method_executor.php @@ -0,0 +1,209 @@ + false, 'error' => $message, 'context' => $context, 'timestamp' => date('c')], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + exit(1); +} + +function ddless_method_success(array $data): void +{ + fwrite(STDOUT, json_encode(array_merge(['ok' => true, 'timestamp' => date('c')], $data), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + exit(0); +} + +function ddless_clean_php_code(string $code): string +{ + $code = trim($code); + if (str_starts_with($code, '')) $code = substr($code, 0, -2); + return trim($code); +} + +function ddless_serialize_value($value, int $depth = 0, int $maxDepth = 10, int $maxArrayItems = 100) +{ + if ($depth > $maxDepth) { + if (is_object($value)) return ['__type' => 'object', '__class' => get_class($value), '__truncated' => true]; + if (is_array($value)) return ['__type' => 'array', '__truncated' => true, '__count' => count($value)]; + return $value; + } + if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) return $value; + if (is_string($value)) return strlen($value) > 10000 ? substr($value, 0, 10000) . "\xe2\x80\xa6 [truncated]" : $value; + if (is_array($value)) { + $result = []; $count = 0; + foreach ($value as $key => $item) { + if ($count >= $maxArrayItems) { $result['__truncated'] = true; $result['__totalItems'] = count($value); break; } + $result[$key] = ddless_serialize_value($item, $depth + 1, $maxDepth, $maxArrayItems); $count++; + } + return $result; + } + if ($value instanceof \DateTimeInterface) return ['__type' => 'datetime', 'formatted' => $value->format('Y-m-d H:i:s'), 'timezone' => $value->getTimezone()->getName(), 'iso8601' => $value->format(\DateTimeInterface::ATOM)]; + if ($value instanceof \JsonSerializable) { try { return ddless_serialize_value($value->jsonSerialize(), $depth + 1, $maxDepth, $maxArrayItems); } catch (\Throwable $e) { return ['__type' => 'object', '__class' => get_class($value), '__error' => $e->getMessage()]; } } + if (method_exists($value, 'toArray')) { try { return ddless_serialize_value($value->toArray(), $depth + 1, $maxDepth, $maxArrayItems); } catch (\Throwable $e) {} } + if (is_object($value)) { + $result = ['__type' => 'object', '__class' => get_class($value)]; + try { + $ref = new \ReflectionClass($value); + foreach ($ref->getProperties() as $prop) { $prop->setAccessible(true); if ($prop->isInitialized($value)) $result[$prop->getName()] = ddless_serialize_value($prop->getValue($value), $depth + 1, $maxDepth, $maxArrayItems); } + } catch (\Throwable $e) { $result['__error'] = $e->getMessage(); } + return $result; + } + if (is_resource($value)) return ['__type' => 'resource', '__resourceType' => get_resource_type($value)]; + return (string)$value; +} + +$inputJson = ''; +if (!empty($GLOBALS['__DDLESS_METHOD_INPUT__'])) { $inputJson = $GLOBALS['__DDLESS_METHOD_INPUT__']; } +elseif (($inputFile = getenv('DDLESS_METHOD_INPUT_FILE')) && $inputFile !== '__ALREADY_LOADED__' && is_file($inputFile)) { $inputJson = file_get_contents($inputFile); @unlink($inputFile); } +else { while (!feof(STDIN)) { $line = fgets(STDIN); if ($line === false) break; $inputJson .= $line; } } + +$input = json_decode(trim($inputJson), true); +if (!is_array($input)) ddless_method_error('Invalid input JSON'); + +$targetClass = $input['class'] ?? ''; +$targetMethod = $input['method'] ?? null; +$parameterCode = $input['parameterCode'] ?? 'return [];'; +$constructorCode = $input['constructorCode'] ?? 'return [];'; + +if (empty($targetMethod)) ddless_method_error('Missing required field: method/function name'); + +$composerAutoload = DDLESS_PROJECT_ROOT . '/autoload.php'; +if (!file_exists($composerAutoload)) { + $composerAutoload = DDLESS_PROJECT_ROOT . '/vendor/autoload.php'; +} +if (!file_exists($composerAutoload)) ddless_method_error('Autoload not found', ['path' => $composerAutoload]); + +if (getenv('DDLESS_DEBUG_MODE') === 'true') { + $debugModule = dirname(__DIR__, 2) . '/debug.php'; + if (file_exists($debugModule)) { + require_once $debugModule; + if (function_exists('ddless_register_stream_wrapper')) { + fwrite(STDERR, "[ddless] Registering stream wrapper BEFORE autoload...\n"); + ddless_register_stream_wrapper(); + } + } +} + +fwrite(STDERR, "[ddless] Loading autoload...\n"); +$webDir = is_dir(DDLESS_PROJECT_ROOT . '/web') ? DDLESS_PROJECT_ROOT . '/web' : DDLESS_PROJECT_ROOT; +chdir($webDir); +$autoloader = require $composerAutoload; +fwrite(STDERR, "[ddless] Autoload loaded.\n"); + +$container = null; +try { + fwrite(STDERR, "[ddless] Booting DrupalKernel...\n"); + $request = \Symfony\Component\HttpFoundation\Request::createFromGlobals(); + $kernel = \Drupal\Core\DrupalKernel::createFromRequest($request, $autoloader, 'prod'); + $kernel->boot(); + $container = $kernel->getContainer(); + fwrite(STDERR, "[ddless] DrupalKernel booted successfully.\n"); +} catch (\Throwable $e) { + ddless_method_error('Failed to boot DrupalKernel: ' . $e->getMessage(), [ + 'file' => $e->getFile(), 'line' => $e->getLine(), + 'trace' => array_slice($e->getTrace(), 0, 5), + ]); +} + +$cleanedConstructorCode = ddless_clean_php_code($constructorCode); +$cleanedParameterCode = ddless_clean_php_code($parameterCode); +$constructorParameters = []; +$methodParameters = []; + +try { $constructorParameters = (function() use ($cleanedConstructorCode, $container) { $result = eval($cleanedConstructorCode); if (!is_array($result)) throw new \InvalidArgumentException('Constructor code must return an array'); return $result; })(); } +catch (\Throwable $e) { ddless_method_error('Error executing constructor code: ' . $e->getMessage(), ['code' => $cleanedConstructorCode]); } + +try { $methodParameters = (function() use ($cleanedParameterCode, $container) { $result = eval($cleanedParameterCode); if (!is_array($result)) throw new \InvalidArgumentException('Parameter code must return an array'); return $result; })(); } +catch (\Throwable $e) { ddless_method_error('Error executing parameter code: ' . $e->getMessage(), ['code' => $cleanedParameterCode]); } + +try { + $startTime = microtime(true); + $startMemory = memory_get_usage(true); + $result = null; $isFunction = false; $isStatic = false; + $isGlobalFunction = empty($targetClass) || strtolower($targetClass) === 'function' || strtolower($targetClass) === 'global'; + + if ($isGlobalFunction) { + $isFunction = true; + if (!function_exists($targetMethod)) ddless_method_error("Function not found: {$targetMethod}"); + $result = call_user_func_array($targetMethod, $methodParameters); + } else { + if (!class_exists($targetClass)) ddless_method_error("Class not found: {$targetClass}"); + if (!method_exists($targetClass, $targetMethod)) ddless_method_error("Method not found: {$targetClass}::{$targetMethod}"); + + $reflection = new \ReflectionClass($targetClass); + $methodReflection = $reflection->getMethod($targetMethod); + $isStatic = $methodReflection->isStatic(); + + $instance = null; + if (!$isStatic) { + try { + if (!empty($constructorParameters)) { + $instance = $reflection->newInstanceArgs($constructorParameters); + } elseif ($container && $container->has($targetClass)) { + $instance = $container->get($targetClass); + } else { + $constructor = $reflection->getConstructor(); + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + $instance = $reflection->newInstance(); + } else { + ddless_method_error('Cannot instantiate class - constructor requires parameters.', [ + 'class' => $targetClass, + 'hint' => 'Provide constructor parameters or use a Drupal service ID.', + ]); + } + } + } catch (\Throwable $e) { + $constructor = $reflection->getConstructor(); + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + try { $instance = $reflection->newInstance(); } + catch (\Throwable $e2) { ddless_method_error('Failed to instantiate: ' . $e->getMessage(), ['fallbackError' => $e2->getMessage()]); } + } else { + ddless_method_error('Cannot instantiate: ' . $e->getMessage(), ['class' => $targetClass]); + } + } + } + + $result = $isStatic ? $methodReflection->invokeArgs(null, $methodParameters) : $methodReflection->invokeArgs($instance, $methodParameters); + } + + $endTime = microtime(true); $endMemory = memory_get_usage(true); + ddless_method_success([ + 'class' => $isFunction ? null : $targetClass, 'method' => $targetMethod, + 'type' => $isFunction ? 'function' : 'method', 'static' => $isStatic, + 'parameters' => ddless_serialize_value($methodParameters), 'result' => ddless_serialize_value($result), + 'metrics' => ['durationMs' => round(($endTime - $startTime) * 1000, 2), 'memoryUsedBytes' => $endMemory - $startMemory, 'memoryPeakBytes' => memory_get_peak_usage(true)], + ]); +} catch (\Throwable $e) { + $endTime = microtime(true); $endMemory = memory_get_usage(true); + $exceptionInfo = ['class' => get_class($e), 'message' => $e->getMessage(), 'code' => $e->getCode(), 'file' => $e->getFile(), 'line' => $e->getLine()]; + $appTrace = []; + foreach ($e->getTrace() as $frame) { + $file = $frame['file'] ?? ''; + if ($file && !str_contains($file, '/vendor/') && !str_contains($file, '\\vendor\\')) { + $appTrace[] = ['file' => $file, 'line' => $frame['line'] ?? 0, 'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? '')]; + if (count($appTrace) >= 5) break; + } + } + if (!empty($appTrace)) $exceptionInfo['trace'] = $appTrace; + ddless_method_success([ + 'class' => isset($isFunction) && $isFunction ? null : ($targetClass ?? null), + 'method' => $targetMethod ?? '', 'type' => (isset($isFunction) && $isFunction) ? 'function' : 'method', + 'static' => $isStatic ?? false, 'parameters' => isset($methodParameters) ? ddless_serialize_value($methodParameters) : [], + 'result' => null, 'exception' => $exceptionInfo, + 'metrics' => ['durationMs' => isset($startTime) ? round(($endTime - $startTime) * 1000, 2) : 0, 'memoryUsedBytes' => isset($startMemory) ? ($endMemory - $startMemory) : 0, 'memoryPeakBytes' => memory_get_peak_usage(true)], + ]); +} diff --git a/src/frameworks/drupal/task_runner.php b/src/frameworks/drupal/task_runner.php new file mode 100644 index 0000000..e7b1863 --- /dev/null +++ b/src/frameworks/drupal/task_runner.php @@ -0,0 +1,255 @@ + $ok, 'durationMs' => round((microtime(true) - $startTime) * 1000, 2)]; + if ($error !== null) $data['error'] = $error; + echo "__DDLESS_TASK_DONE__:" . json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; +} + +function ddless_clean_php_code(string $code): string { + $code = trim($code); + if (str_starts_with($code, '')) $code = substr($code, 0, -2); + return trim($code); +} + +function ddless_prompt_with_rule(string $promptType, string $message, array $extra, ?callable $rule): string { + $validationError = null; + while (true) { + $data = array_merge(['promptType' => $promptType, 'message' => $message], $extra); + if ($validationError !== null) $data['validationError'] = $validationError; + ddless_task_emit('prompt', $data); + $input = trim((string)fgets(STDIN)); + if ($rule === null) return $input; + $result = $rule($input); + if ($result === true || $result === null) return $input; + $validationError = is_string($result) ? $result : 'Invalid value.'; + } +} + +class DdlessChart { + private string $chartType = 'line'; private string $chartTitle = ''; + private array $labels = []; private array $datasets = []; + public function title(string $title): self { $this->chartTitle = $title; return $this; } + public function line(): self { $this->chartType = 'line'; return $this; } + public function bar(): self { $this->chartType = 'bar'; return $this; } + public function pie(): self { $this->chartType = 'pie'; return $this; } + public function doughnut(): self { $this->chartType = 'doughnut'; return $this; } + public function area(): self { $this->chartType = 'area'; return $this; } + public function labels(array $labels): self { $this->labels = $labels; return $this; } + public function dataset(string $label, array $data): self { $this->datasets[] = ['label' => $label, 'data' => $data]; return $this; } + public function render(): void { ddless_task_emit('chart', ['title' => $this->chartTitle, 'chartType' => $this->chartType, 'labels' => $this->labels, 'datasets' => $this->datasets]); } +} + +$inputJson = $GLOBALS['__DDLESS_TASK_INPUT__'] ?? null; +if (!$inputJson) { ddless_task_emit('error', ['message' => 'Task input not found.']); ddless_task_done(false, $taskStartTime, 'Task input not found.'); exit(1); } + +$input = json_decode($inputJson, true); +if (!is_array($input)) { ddless_task_emit('error', ['message' => 'Invalid task input JSON.']); ddless_task_done(false, $taskStartTime, 'Invalid task input JSON.'); exit(1); } + +$userCode = $input['code'] ?? ''; +$imports = $input['imports'] ?? []; +$GLOBALS['__DDLESS_IMPORTS_PATH__'] = $input['importsPath'] ?? null; + +if (trim($userCode) === '') { ddless_task_emit('warn', ['message' => 'No code to execute.']); ddless_task_done(true, $taskStartTime); exit(0); } + +$composerAutoload = DDLESS_PROJECT_ROOT . '/autoload.php'; +if (!file_exists($composerAutoload)) $composerAutoload = DDLESS_PROJECT_ROOT . '/vendor/autoload.php'; +if (!file_exists($composerAutoload)) { ddless_task_emit('error', ['message' => 'Autoload not found.']); ddless_task_done(false, $taskStartTime, 'Autoload not found.'); exit(1); } + +$webDir = is_dir(DDLESS_PROJECT_ROOT . '/web') ? DDLESS_PROJECT_ROOT . '/web' : DDLESS_PROJECT_ROOT; +chdir($webDir); +$autoloader = require $composerAutoload; + +$container = null; +try { + $request = \Symfony\Component\HttpFoundation\Request::createFromGlobals(); + $kernel = \Drupal\Core\DrupalKernel::createFromRequest($request, $autoloader, 'prod'); + $kernel->boot(); + $container = $kernel->getContainer(); +} catch (\Throwable $e) { + ddless_task_emit('error', ['message' => 'Failed to boot Drupal: ' . $e->getMessage()]); + ddless_task_done(false, $taskStartTime, 'Failed to boot Drupal: ' . $e->getMessage()); + exit(1); +} + +class DdlessDrupalTaskRunnerCommand { + private $__container; + private $__progressId = null; private $__progressCurrent = 0; private $__progressTotal = 0; + + public function setContainer($container): void { $this->__container = $container; } + public function getContainer() { return $this->__container; } + + public function service(string $id) { return \Drupal::service($id); } + public function entityTypeManager() { return \Drupal::entityTypeManager(); } + public function database() { return \Drupal::database(); } + public function config(string $name) { return \Drupal::config($name); } + public function state() { return \Drupal::state(); } + + public function info($string) { ddless_task_emit('info', ['message' => (string)$string]); } + public function error($string) { ddless_task_emit('error', ['message' => (string)$string]); } + public function warn($string) { ddless_task_emit('warn', ['message' => (string)$string]); } + public function line($string) { ddless_task_emit('line', ['message' => (string)$string]); } + public function comment($string) { ddless_task_emit('comment', ['message' => (string)$string]); } + public function newLine($count = 1) { ddless_task_emit('newline', ['count' => $count]); return $this; } + public function alert($string) { ddless_task_emit('alert', ['message' => (string)$string]); } + public function listing(array $items) { ddless_task_emit('listing', ['items' => array_values(array_map('strval', $items))]); } + public function hr() { ddless_task_emit('hr', []); } + public function clipboard($text) { ddless_task_emit('clipboard', ['text' => (string)$text]); } + public function link($url, $label = '') { ddless_task_emit('link', ['url' => (string)$url, 'label' => $label ?: (string)$url]); } + + public function table($headers, $rows) { + $plainRows = []; + if ($rows instanceof \Traversable) $rows = iterator_to_array($rows); + foreach ($rows as $row) { + if ($row instanceof \Traversable) $row = iterator_to_array($row); + if (is_object($row)) $row = (array) $row; + $plainRows[] = array_values(array_map('strval', $row)); + } + ddless_task_emit('table', ['headers' => array_values(array_map('strval', (array)$headers)), 'rows' => $plainRows]); + } + + public function json($data) { ddless_task_emit('json', ['data' => $data]); } + public function chart(): DdlessChart { return new DdlessChart(); } + + public function spin(callable $callback, string $label = 'Processing...') { + ddless_task_emit('spin', ['label' => $label, 'state' => 'start']); + try { $result = $callback(); ddless_task_emit('spin', ['label' => $label, 'state' => 'done']); return $result; } + catch (\Throwable $e) { ddless_task_emit('spin', ['label' => $label, 'state' => 'error', 'message' => $e->getMessage()]); throw $e; } + } + + public function time(string $label, callable $callback) { + $start = microtime(true); $result = $callback(); + ddless_task_emit('time', ['label' => $label, 'durationMs' => round((microtime(true) - $start) * 1000, 2)]); return $result; + } + + public function progress(int $total, string $label = ''): self { + $this->__progressId = 'prog_' . uniqid(); $this->__progressCurrent = 0; $this->__progressTotal = $total; + ddless_task_emit('progress', ['progressId' => $this->__progressId, 'current' => 0, 'total' => $total, 'percent' => 0, 'label' => $label]); + return $this; + } + + public function advance(int $step = 1, ?string $label = null): self { + if (!$this->__progressId) return $this; + $this->__progressCurrent = min($this->__progressCurrent + $step, $this->__progressTotal); + $percent = $this->__progressTotal > 0 ? round(($this->__progressCurrent / $this->__progressTotal) * 100, 1) : 0; + ddless_task_emit('progress', ['progressId' => $this->__progressId, 'current' => $this->__progressCurrent, 'total' => $this->__progressTotal, 'percent' => $percent, 'label' => $label]); + return $this; + } + + public function ask($question, $default = null, ?callable $rule = null) { $input = ddless_prompt_with_rule('ask', (string)$question, ['promptDefault' => (string)($default ?? '')], $rule); return $input !== '' ? $input : ($default ?? ''); } + public function secret($question, $fallback = true, ?callable $rule = null) { return ddless_prompt_with_rule('secret', (string)$question, [], $rule); } + public function confirm($question, $default = false, ?callable $rule = null) { return ddless_prompt_with_rule('confirm', (string)$question, ['promptDefault' => $default ? 'true' : 'false'], $rule) === 'true'; } + public function choice($question, $choices, $default = null, $attempts = null, $multiple = false, ?callable $rule = null) { if ($choices instanceof \Traversable) $choices = iterator_to_array($choices); return ddless_prompt_with_rule('choice', (string)$question, ['promptOptions' => array_values((array)$choices), 'promptDefault' => $default ?? $choices[0] ?? ''], $rule); } + public function anticipate($question, $suggestions, $default = null, ?callable $rule = null) { if ($suggestions instanceof \Traversable) $suggestions = iterator_to_array($suggestions); return ddless_prompt_with_rule('anticipate', (string)$question, ['promptOptions' => array_values((array)$suggestions), 'promptDefault' => (string)($default ?? '')], $rule); } + public function date($question, ?string $default = null, ?callable $rule = null) { return ddless_prompt_with_rule('date', (string)$question, ['promptDefault' => $default ?? date('Y-m-d')], $rule); } + public function datetime($question, ?string $default = null, ?callable $rule = null) { return ddless_prompt_with_rule('datetime', (string)$question, ['promptDefault' => $default ?? date('Y-m-d H:i')], $rule); } + + public function import(string $filename, string $delimiter = ',', bool $headers = true, string $encoding = 'auto'): \Generator { + $importsPath = $GLOBALS['__DDLESS_IMPORTS_PATH__'] ?? null; + if (!$importsPath) throw new \RuntimeException("No imports directory configured."); + $filePath = rtrim($importsPath, '/\\') . DIRECTORY_SEPARATOR . basename($filename); + if (!is_file($filePath)) throw new \RuntimeException("Import file not found: {$filename}"); + $handle = fopen($filePath, 'r'); + if ($handle === false) throw new \RuntimeException("Failed to open import file: {$filename}"); + try { + $encodingDetected = null; + if ($encoding === 'auto') { + $sample = fread($handle, 8192); if ($sample === false || $sample === '') return; + if (str_starts_with($sample, "\xEF\xBB\xBF")) { $encodingDetected = 'UTF-8'; } else { $encodingDetected = mb_detect_encoding($sample, ['UTF-8', 'Windows-1252', 'ISO-8859-1'], true) ?: 'UTF-8'; } + rewind($handle); $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") rewind($handle); + } else { $encodingDetected = $encoding; } + $needsConversion = $encodingDetected !== null && $encodingDetected !== 'UTF-8' && $encodingDetected !== 'utf-8'; + $headerRow = null; + if ($headers) { $firstLine = fgetcsv($handle, 0, $delimiter); if ($firstLine === false || $firstLine === null) return; if ($needsConversion) $firstLine = array_map(fn($v) => mb_convert_encoding((string)$v, 'UTF-8', $encodingDetected), $firstLine); $headerRow = $firstLine; } + while (($row = fgetcsv($handle, 0, $delimiter)) !== false) { + if ($row === null) continue; if ($needsConversion) $row = array_map(fn($v) => mb_convert_encoding((string)$v, 'UTF-8', $encodingDetected), $row); + if ($headerRow !== null) { $row = array_pad($row, count($headerRow), ''); yield array_combine($headerRow, array_slice($row, 0, count($headerRow))); } else { yield $row; } + } + } finally { fclose($handle); } + } + + public function export(string $filename, callable $callback, string $delimiter = ';'): void { + if (!str_ends_with(strtolower($filename), '.csv')) $filename .= '.csv'; + $page = 0; $totalRows = 0; $headersSent = false; $exportId = 'exp_' . uniqid(); + ddless_task_emit('export_begin', ['exportId' => $exportId, 'filename' => $filename]); + try { + while (true) { + $rows = $callback($page); if ($rows === null || $rows === [] || $rows === false) break; + if (!is_array($rows)) throw new \RuntimeException('Export callback must return an array.'); + $csvLines = []; + if (!$headersSent) { $firstRow = reset($rows); if (is_array($firstRow) || is_object($firstRow)) $csvLines[] = ddless_csv_encode_row(array_keys((array)$firstRow), $delimiter); $headersSent = true; } + foreach ($rows as $row) { $csvLines[] = ddless_csv_encode_row(array_values((array)$row), $delimiter); $totalRows++; } + ddless_task_emit('export_chunk', ['exportId' => $exportId, 'csv' => implode("\n", $csvLines) . "\n", 'totalRows' => $totalRows]); + $page++; + } + ddless_task_emit('export_end', ['exportId' => $exportId, 'filename' => $filename, 'totalRows' => $totalRows]); + } catch (\Throwable $e) { ddless_task_emit('export_error', ['exportId' => $exportId, 'error' => $e->getMessage(), 'totalRows' => $totalRows]); throw $e; } + } +} + +try { + $command = new DdlessDrupalTaskRunnerCommand(); + $command->setContainer($container); + + $useStatements = ''; + foreach ($imports as $import) { $import = trim($import); if ($import !== '') $useStatements .= "use {$import};\n"; } + + $cleanedCode = ddless_clean_php_code($userCode); + $evalCode = $useStatements . "\n" . $cleanedCode; + + $closure = \Closure::bind(function () use ($evalCode) { return eval($evalCode); }, $command, DdlessDrupalTaskRunnerCommand::class); + $closure(); + ddless_task_done(true, $taskStartTime); +} catch (\Throwable $e) { + if (str_contains($e->getFile(), "eval()'d code")) { + ddless_task_emit('error', ['message' => $e->getMessage()]); + ddless_task_done(false, $taskStartTime, $e->getMessage()); + exit(1); + } + ddless_task_emit('exception', ['exceptionClass' => get_class($e), 'message' => $e->getMessage(), 'exceptionCode' => $e->getCode(), 'exceptionFile' => $e->getFile(), 'exceptionLine' => $e->getLine()]); + ddless_task_done(true, $taskStartTime); +}