├── src ├── GRPC │ ├── Exception │ │ ├── CompileException.php │ │ └── ServiceRegistrationException.php │ ├── ProtoRepository │ │ ├── ProtoFilesRepositoryInterface.php │ │ ├── FileRepository.php │ │ └── CompositeRepository.php │ ├── Generator │ │ ├── GeneratorRegistryInterface.php │ │ ├── GeneratorInterface.php │ │ └── GeneratorRegistry.php │ ├── LocatorInterface.php │ ├── UnaryCallInterface.php │ └── Internal │ │ ├── CommandExecutor.php │ │ ├── UnaryCall.php │ │ ├── ProtocCommandBuilder.php │ │ ├── ServiceLocator.php │ │ ├── ProtoCompiler.php │ │ ├── Dispatcher.php │ │ └── Invoker.php ├── Centrifugo │ ├── Exception │ │ ├── CentrifugoException.php │ │ └── ConfigurationException.php │ ├── ServiceInterface.php │ ├── ErrorHandlerInterface.php │ ├── Internal │ │ ├── LogErrorHandler.php │ │ ├── Broadcast.php │ │ ├── Dispatcher.php │ │ ├── ServiceRegistry.php │ │ ├── InterceptorRegistry.php │ │ └── Server.php │ ├── RegistryInterface.php │ └── Interceptor │ │ └── RegistryInterface.php ├── Exception │ └── DispatcherNotFoundException.php ├── Tcp │ ├── Response │ │ ├── ResponseInterface.php │ │ ├── ContinueRead.php │ │ ├── CloseConnection.php │ │ └── RespondMessage.php │ ├── Service │ │ ├── Exception │ │ │ └── NotFoundException.php │ │ ├── ServiceInterface.php │ │ └── RegistryInterface.php │ ├── Internal │ │ ├── Dispatcher.php │ │ ├── InterceptorRegistry.php │ │ ├── ServiceRegistry.php │ │ └── Server.php │ └── Interceptor │ │ └── RegistryInterface.php ├── Queue │ ├── PayloadDeserializerInterface.php │ ├── PipelineRegistryInterface.php │ └── Internal │ │ ├── OptionsFactory.php │ │ ├── Queue.php │ │ ├── PayloadDeserializer.php │ │ ├── Dispatcher.php │ │ └── RPCPipelineRegistry.php ├── Bootloader │ ├── LockBootloader.php │ ├── MetricsBootloader.php │ ├── LoggerBootloader.php │ ├── CommandBootloader.php │ ├── HttpBootloader.php │ ├── CacheBootloader.php │ ├── RoadRunnerBootloader.php │ ├── QueueBootloader.php │ ├── ScaffolderBootloader.php │ ├── TcpBootloader.php │ ├── CentrifugoBootloader.php │ └── GRPCBootloader.php ├── RoadRunnerMode.php ├── Config │ ├── QueueConfig.php │ ├── CentrifugoConfig.php │ ├── TcpConfig.php │ └── GRPCConfig.php ├── Logger │ ├── RoadRunnerLogsMode.php │ ├── Handler.php │ └── Internal │ │ └── RoadRunnerJsonFormatter.php ├── Console │ └── Command │ │ ├── GRPC │ │ ├── ListCommand.php │ │ └── GenerateCommand.php │ │ ├── Queue │ │ ├── PauseCommand.php │ │ ├── ResumeCommand.php │ │ └── ListCommand.php │ │ └── Scaffolder │ │ ├── TcpServiceCommand.php │ │ └── CentrifugoHandlerCommand.php ├── Scaffolder │ └── Declaration │ │ ├── TcpServiceDeclaration.php │ │ └── CentrifugoHandlerDeclaration.php ├── FallbackDispatcher.php └── Http │ └── Internal │ └── Dispatcher.php ├── LICENSE ├── composer.json └── README.md /src/GRPC/Exception/CompileException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function getProtos(): iterable; 15 | } 16 | -------------------------------------------------------------------------------- /src/GRPC/Generator/GeneratorRegistryInterface.php: -------------------------------------------------------------------------------- 1 | protoFiles; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Tcp/Service/ServiceInterface.php: -------------------------------------------------------------------------------- 1 | object]. 16 | * 17 | * @return array, \ReflectionClass> 18 | */ 19 | public function getServices(): array; 20 | } 21 | -------------------------------------------------------------------------------- /src/GRPC/UnaryCallInterface.php: -------------------------------------------------------------------------------- 1 | Lock::class, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/GRPC/Internal/CommandExecutor.php: -------------------------------------------------------------------------------- 1 | repositories = $repositories; 17 | } 18 | 19 | public function getProtos(): iterable 20 | { 21 | foreach ($this->repositories as $repository) { 22 | yield from $repository->getProtos(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/LogErrorHandler.php: -------------------------------------------------------------------------------- 1 | reporter->report($e); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Queue/PipelineRegistryInterface.php: -------------------------------------------------------------------------------- 1 | close) { 19 | return TcpResponse::RespondClose; 20 | } 21 | 22 | return TcpResponse::Respond; 23 | } 24 | 25 | public function getBody(): string 26 | { 27 | return $this->body; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Bootloader/MetricsBootloader.php: -------------------------------------------------------------------------------- 1 | static fn(RPCInterface $rpc): MetricsInterface => new Metrics($rpc), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Tcp/Service/RegistryInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface RegistryInterface 13 | { 14 | /** 15 | * @param non-empty-string $server 16 | */ 17 | public function getService(string $server): ServiceInterface; 18 | 19 | /** 20 | * @param non-empty-string $server 21 | * @param TService $service 22 | */ 23 | public function register(string $server, Autowire|ServiceInterface|string $service): void; 24 | 25 | /** 26 | * @param non-empty-string $server 27 | */ 28 | public function hasService(string $server): bool; 29 | } 30 | -------------------------------------------------------------------------------- /src/RoadRunnerMode.php: -------------------------------------------------------------------------------- 1 | getMode(); 25 | 26 | return self::tryFrom($value) ?? self::Unknown; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Config/QueueConfig.php: -------------------------------------------------------------------------------- 1 | $pipelines 26 | */ 27 | public function getPipelines(): array 28 | { 29 | return $this->pipelines; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Logger/RoadRunnerLogsMode.php: -------------------------------------------------------------------------------- 1 | get('RR_LOGGER_MODE', ''); 23 | 24 | return RoadRunnerLogsMode::tryFrom($value) ?? RoadRunnerLogsMode::Production; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/Broadcast.php: -------------------------------------------------------------------------------- 1 | formatTopics($this->toArray($topics)); 23 | 24 | /** @var string $message */ 25 | foreach ($this->toArray($messages) as $message) { 26 | $this->api->broadcast($topics, $message); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Centrifugo/RegistryInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | interface RegistryInterface 14 | { 15 | /** 16 | * Get service by request type. 17 | */ 18 | public function getService(RequestType $requestType): ?ServiceInterface; 19 | 20 | /** 21 | * Register a service for a given request type. 22 | * 23 | * @param TService $service 24 | */ 25 | public function register(RequestType $requestType, Autowire|ServiceInterface|string $service): void; 26 | 27 | /** 28 | * Check if service is registered for a given request type. 29 | */ 30 | public function hasService(RequestType $requestType): bool; 31 | } 32 | -------------------------------------------------------------------------------- /src/GRPC/Internal/UnaryCall.php: -------------------------------------------------------------------------------- 1 | context; 26 | } 27 | 28 | public function getMethod(): Method 29 | { 30 | return $this->method; 31 | } 32 | 33 | public function getMessage(): Message 34 | { 35 | return $this->message; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Centrifugo/Interceptor/RegistryInterface.php: -------------------------------------------------------------------------------- 1 | 13 | * @psalm-type TInterceptor = Autowire|InterceptorInterface|class-string 14 | */ 15 | interface RegistryInterface 16 | { 17 | /** 18 | * @param non-empty-string $type 19 | * 20 | * @return list 21 | */ 22 | public function getInterceptors(string $type): array; 23 | 24 | /** 25 | * @param non-empty-string $type 26 | * @param TInterceptor|TLegacyInterceptor $interceptor 27 | */ 28 | public function register(string $type, Autowire|CoreInterceptorInterface|InterceptorInterface|string $interceptor): void; 29 | } 30 | -------------------------------------------------------------------------------- /src/GRPC/Generator/GeneratorRegistry.php: -------------------------------------------------------------------------------- 1 | hasGenerator($generator::class)) { 20 | $this->generators[] = $generator; 21 | } 22 | } 23 | 24 | /** 25 | * @return GeneratorInterface[] 26 | */ 27 | public function getGenerators(): array 28 | { 29 | return $this->generators; 30 | } 31 | 32 | public function hasGenerator(string $name): bool 33 | { 34 | foreach ($this->generators as $generator) { 35 | if ($generator::class === $name) { 36 | return true; 37 | } 38 | } 39 | 40 | return false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Tcp/Internal/Dispatcher.php: -------------------------------------------------------------------------------- 1 | container->get(Server::class); 29 | /** @var WorkerInterface $worker */ 30 | $worker = $this->container->get(WorkerInterface::class); 31 | 32 | $server->serve($worker); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Tcp/Interceptor/RegistryInterface.php: -------------------------------------------------------------------------------- 1 | 13 | * @psalm-type TInterceptor = Autowire|InterceptorInterface|class-string 14 | */ 15 | interface RegistryInterface 16 | { 17 | /** 18 | * @param non-empty-string $server 19 | * 20 | * @return array 21 | */ 22 | public function getInterceptors(string $server): array; 23 | 24 | /** 25 | * @param non-empty-string $server 26 | * @param TInterceptor|TLegacyInterceptor $interceptor 27 | */ 28 | public function register( 29 | string $server, 30 | Autowire|CoreInterceptorInterface|InterceptorInterface|string $interceptor, 31 | ): void; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Spiral Scout 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Config/CentrifugoConfig.php: -------------------------------------------------------------------------------- 1 | [], 22 | 'interceptors' => [], 23 | ]; 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function getServices(): array 29 | { 30 | return $this->config['services'] ?? []; 31 | } 32 | 33 | /** 34 | * @return array> 35 | */ 36 | public function getInterceptors(): array 37 | { 38 | return $this->config['interceptors'] ?? []; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/Dispatcher.php: -------------------------------------------------------------------------------- 1 | container->get(Server::class); 33 | /** @var CentrifugoWorker $worker */ 34 | $worker = $this->container->get(CentrifugoWorkerInterface::class); 35 | 36 | $server->serve($worker); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Console/Command/GRPC/ListCommand.php: -------------------------------------------------------------------------------- 1 | getServices(); 18 | 19 | if ($services === []) { 20 | $this->writeln('No GRPC services were found.'); 21 | 22 | return self::SUCCESS; 23 | } 24 | 25 | $table = $this->table([ 26 | 'Service:', 27 | 'Implementation:', 28 | 'File:', 29 | ]); 30 | 31 | foreach ($services as $interface => $reflection) { 32 | $table->addRow([ 33 | $interface::NAME, 34 | $reflection->getName(), 35 | $reflection->getFileName(), 36 | ]); 37 | } 38 | 39 | $table->render(); 40 | 41 | return self::SUCCESS; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Console/Command/Queue/PauseCommand.php: -------------------------------------------------------------------------------- 1 | pipeline !== ''); 30 | 31 | if ($this->isVerbose()) { 32 | $this->info(\sprintf('Pausing pipeline [%s]...', $this->pipeline)); 33 | } 34 | 35 | $jobs->pause($this->pipeline); 36 | 37 | $this->info(\sprintf('Pipeline [%s] has been paused.', $this->pipeline)); 38 | 39 | return self::SUCCESS; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Console/Command/Scaffolder/TcpServiceCommand.php: -------------------------------------------------------------------------------- 1 | createDeclaration(TcpServiceDeclaration::class); 30 | 31 | $this->writeDeclaration($declaration); 32 | 33 | return self::SUCCESS; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Scaffolder/Declaration/TcpServiceDeclaration.php: -------------------------------------------------------------------------------- 1 | namespace->addUse(ServiceInterface::class); 20 | $this->namespace->addUse(Request::class); 21 | $this->namespace->addUse(RespondMessage::class); 22 | $this->namespace->addUse(ResponseInterface::class); 23 | 24 | $this->class->addImplement(ServiceInterface::class); 25 | $this->class->setFinal(); 26 | 27 | $this->class->addMethod('handle') 28 | ->setPublic() 29 | ->addBody("return new RespondMessage('some message', true);") 30 | ->setReturnType(ResponseInterface::class) 31 | ->addParameter('request') 32 | ->setType(Request::class) 33 | ; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Config/TcpConfig.php: -------------------------------------------------------------------------------- 1 | [], 22 | 'interceptors' => [], 23 | ]; 24 | 25 | /** 26 | * @return array|ServiceInterface> 27 | */ 28 | public function getServices(): array 29 | { 30 | return $this->config['services'] ?? []; 31 | } 32 | 33 | /** 34 | * @return array> 35 | */ 36 | public function getInterceptors(): array 37 | { 38 | return $this->config['interceptors'] ?? []; 39 | } 40 | 41 | public function isDebugMode(): bool 42 | { 43 | return (bool) $this->config['debug']; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Logger/Handler.php: -------------------------------------------------------------------------------- 1 | setFormatter(new RoadRunnerJsonFormatter($loggerPrefix, $loggerMode)); 29 | } 30 | 31 | protected function write(array|LogRecord $record): void 32 | { 33 | $log = \is_array($record['formatted']) 34 | ? \json_encode($record['formatted'], \JSON_THROW_ON_ERROR) 35 | : $record['formatted']; 36 | 37 | $this->logger 38 | ->log($log . PHP_EOL); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Bootloader/LoggerBootloader.php: -------------------------------------------------------------------------------- 1 | addHandler('roadrunner', $mode === RoadRunnerMode::Unknown 34 | ? new ErrorLogHandler() 35 | : new Handler( 36 | logger: $logger, 37 | loggerPrefix: (string) $env->get('RR_LOGGER_PREFIX'), 38 | loggerMode: $loggerMode, 39 | level: Monologger::toMonologLevel($env->get('MONOLOG_DEFAULT_LEVEL', 'INFO')), 40 | )); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Bootloader/CommandBootloader.php: -------------------------------------------------------------------------------- 1 | configureExtensions($console, $container); 20 | } 21 | 22 | private function configureExtensions(ConsoleBootloader $console, Container $container): void 23 | { 24 | if ($container->has(JobsInterface::class)) { 25 | $this->configureJobs($console); 26 | } 27 | 28 | if ($container->has(LocatorInterface::class)) { 29 | $this->configureGrpc($console); 30 | } 31 | } 32 | 33 | private function configureJobs(ConsoleBootloader $console): void 34 | { 35 | $console->addCommand(Queue\PauseCommand::class); 36 | $console->addCommand(Queue\ResumeCommand::class); 37 | $console->addCommand(Queue\ListCommand::class); 38 | } 39 | 40 | private function configureGrpc(ConsoleBootloader $console): void 41 | { 42 | $console->addCommand(GRPC\GenerateCommand::class); 43 | $console->addCommand(GRPC\ListCommand::class); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Console/Command/Queue/ResumeCommand.php: -------------------------------------------------------------------------------- 1 | pipeline !== ''); 30 | 31 | if ($this->isVerbose()) { 32 | $this->info(\sprintf('Trying to start consuming pipeline [%s]...', $this->pipeline)); 33 | } 34 | 35 | $queue = $registry->getPipeline($this->pipeline); 36 | 37 | if ($queue->isPaused()) { 38 | $this->info(\sprintf('Pipeline [%s] has been started consuming tasks.', $this->pipeline)); 39 | $queue->resume(); 40 | } else { 41 | $this->warning(\sprintf('Pipeline [%s] is not paused.', $this->pipeline)); 42 | return self::FAILURE; 43 | } 44 | 45 | return self::SUCCESS; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Bootloader/HttpBootloader.php: -------------------------------------------------------------------------------- 1 | PSR7WorkerInterface::class, 32 | 33 | PSR7WorkerInterface::class => static fn( 34 | WorkerInterface $worker, 35 | ServerRequestFactoryInterface $requests, 36 | StreamFactoryInterface $streams, 37 | UploadedFileFactoryInterface $uploads, 38 | ): PSR7WorkerInterface => new PSR7Worker($worker, $requests, $streams, $uploads), 39 | ]; 40 | } 41 | 42 | public function boot(KernelInterface $kernel): void 43 | { 44 | /** @psalm-suppress InvalidArgument */ 45 | $kernel->addDispatcher(Dispatcher::class); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Bootloader/CacheBootloader.php: -------------------------------------------------------------------------------- 1 | static fn( 29 | RPCInterface $rpc, 30 | SerializerInterface $serializer, 31 | ): FactoryInterface => new Factory($rpc, $serializer), 32 | 33 | SerializerInterface::class => DefaultSerializer::class, 34 | ]; 35 | } 36 | 37 | public function defineBindings(): array 38 | { 39 | return [ 40 | StorageInterface::class => 41 | /** @param non-empty-string $driver */ 42 | static fn( 43 | FactoryInterface $factory, 44 | string $driver, 45 | ): StorageInterface => $factory->select($driver), 46 | ]; 47 | } 48 | 49 | public function init(BaseCacheBootloader $cacheBootloader): void 50 | { 51 | $cacheBootloader->registerTypeAlias(StorageInterface::class, 'roadrunner'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Queue/Internal/OptionsFactory.php: -------------------------------------------------------------------------------- 1 | self::fromQueueOptions($options), 24 | default => $options, 25 | }; 26 | } 27 | 28 | public static function fromQueueOptions(OptionsInterface $from): JobsOptionsInterface 29 | { 30 | $options = new Options($from->getDelay() ?? JobsOptionsInterface::DEFAULT_DELAY); 31 | if ($from instanceof ExtendedOptionsInterface) { 32 | /** @var array|non-empty-string $values */ 33 | foreach ($from->getHeaders() as $header => $values) { 34 | $options = $options->withHeader($header, $values); 35 | } 36 | } 37 | 38 | return $options; 39 | } 40 | 41 | /** 42 | * Creates specified options for the concrete driver, if needed. 43 | */ 44 | public static function fromCreateInfo(CreateInfoInterface $connector): ?JobsOptionsInterface 45 | { 46 | $config = $connector->toArray(); 47 | 48 | return match ($connector->getDriver()) { 49 | Driver::Kafka => new KafkaOptions($config['topic'] ?? 'default'), 50 | default => null, 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Bootloader/RoadRunnerBootloader.php: -------------------------------------------------------------------------------- 1 | static fn( 26 | GlobalEnvironmentInterface $env, 27 | ): EnvironmentInterface => new Environment($env->getAll()), 28 | 29 | Environment::class => EnvironmentInterface::class, 30 | 31 | RPC::class => RPCInterface::class, 32 | RPCInterface::class => 33 | /** @psalm-suppress ArgumentTypeCoercion */ 34 | static fn( 35 | EnvironmentInterface $env, 36 | ): RPCInterface => new RPC(Relay::create($env->getRPCAddress())), 37 | 38 | WorkerInterface::class => static fn( 39 | EnvironmentInterface $env, 40 | ): WorkerInterface => Worker::createFromEnvironment($env), 41 | 42 | Worker::class => WorkerInterface::class, 43 | ]; 44 | } 45 | 46 | public function init(AbstractKernel $kernel): void 47 | { 48 | // Register Fallback Dispatcher after all dispatchers 49 | // It will be called if no other dispatcher can handle RoadRunner request 50 | $kernel->bootstrapped(static function (KernelInterface $kernel): void { 51 | /** @psalm-suppress InvalidArgument */ 52 | $kernel->addDispatcher(FallbackDispatcher::class); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/GRPC/Internal/ProtocCommandBuilder.php: -------------------------------------------------------------------------------- 1 | &1', 25 | $this->protocBinaryPath ? '--plugin=' . $this->protocBinaryPath : '', 26 | \escapeshellarg($tmpDir), 27 | \escapeshellarg($tmpDir), 28 | $this->buildDirs($protoDir), 29 | \implode(' ', \array_map('escapeshellarg', $this->getProtoFiles($protoDir))), 30 | ); 31 | } 32 | 33 | /** 34 | * Include all proto files from the directory. 35 | */ 36 | private function getProtoFiles(string $protoDir): array 37 | { 38 | $filtered = \array_filter( 39 | $this->files->getFiles($protoDir), 40 | static fn(string $file) => \str_ends_with($file, '.proto'), 41 | ); 42 | return \array_map(static fn(string $path): string => \realpath($path) ?: $path, $filtered); 43 | } 44 | 45 | private function buildDirs(string $protoDir): string 46 | { 47 | $dirs = \array_filter([ 48 | // The current directory must be first in the import path list to avoid proto-file name conflicts. 49 | $protoDir, 50 | $this->config->getServicesBasePath(), 51 | ]); 52 | 53 | if ($dirs === []) { 54 | return ''; 55 | } 56 | 57 | return ' -I=' . \implode(' -I=', \array_map( 58 | static fn(string$dir): string => \escapeshellarg(\realpath($dir) ?: $dir), 59 | $dirs, 60 | )); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/FallbackDispatcher.php: -------------------------------------------------------------------------------- 1 | mode) { 32 | RoadRunnerMode::Http => $this->throwException(Http::class), 33 | RoadRunnerMode::Jobs => $this->throwException(Queue::class), 34 | RoadRunnerMode::Grpc => $this->throwException(GRPC::class), 35 | RoadRunnerMode::Tcp => $this->throwException(Tcp::class), 36 | RoadRunnerMode::Centrifuge => $this->throwException(Centrifugo::class), 37 | RoadRunnerMode::Temporal => throw new DispatcherNotFoundException(self::TEMPORAL_ERROR), 38 | RoadRunnerMode::Unknown => null, 39 | }; 40 | } 41 | 42 | /** 43 | * @param class-string $class 44 | * 45 | * @throws DispatcherNotFoundException 46 | */ 47 | private function throwException(string $class): never 48 | { 49 | throw new DispatcherNotFoundException(\sprintf(self::PLUGIN_ERROR, $this->mode->name, $class)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Tcp/Internal/InterceptorRegistry.php: -------------------------------------------------------------------------------- 1 | > */ 21 | private array $interceptors = []; 22 | 23 | public function __construct( 24 | array $interceptors, 25 | private readonly ContainerInterface $container, 26 | ) { 27 | foreach ($interceptors as $server => $values) { 28 | if (!\is_array($values)) { 29 | $values = [$values]; 30 | } 31 | 32 | foreach ($values as $interceptor) { 33 | $this->register($server, $interceptor); 34 | } 35 | } 36 | } 37 | 38 | public function register( 39 | string $server, 40 | Autowire|CoreInterceptorInterface|InterceptorInterface|string $interceptor, 41 | ): void { 42 | $this->interceptors[$server][] = $interceptor; 43 | } 44 | 45 | public function getInterceptors(string $server): array 46 | { 47 | $interceptors = []; 48 | foreach ($this->interceptors[$server] ?? [] as $value) { 49 | $interceptor = match (true) { 50 | $value instanceof CoreInterceptorInterface, $value instanceof InterceptorInterface => $value, 51 | $value instanceof Autowire => $value->resolve($this->container->get(FactoryInterface::class)), 52 | default => $this->container->get($value), 53 | }; 54 | 55 | \assert($interceptor instanceof CoreInterceptorInterface); 56 | 57 | $interceptors[] = $interceptor; 58 | } 59 | 60 | return $interceptors; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Tcp/Internal/ServiceRegistry.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $services = []; 23 | 24 | public function __construct( 25 | array $services, 26 | private readonly ContainerInterface $container, 27 | ) { 28 | foreach ($services as $server => $service) { 29 | $this->register($server, $service); 30 | } 31 | } 32 | 33 | /** 34 | * @param non-empty-string $server 35 | * @param TService $service 36 | */ 37 | public function register(string $server, Autowire|ServiceInterface|string $service): void 38 | { 39 | $this->services[$server] = $service; 40 | } 41 | 42 | /** 43 | * @param non-empty-string $server 44 | */ 45 | public function getService(string $server): ServiceInterface 46 | { 47 | if (!$this->hasService($server)) { 48 | throw new NotFoundException($server); 49 | } 50 | 51 | $service = match (true) { 52 | $this->services[$server] instanceof ServiceInterface => $this->services[$server], 53 | $this->services[$server] instanceof Autowire => $this->services[$server]->resolve( 54 | $this->container->get(FactoryInterface::class), 55 | ), 56 | default => $this->container->get($this->services[$server]), 57 | }; 58 | 59 | \assert($service instanceof ServiceInterface); 60 | 61 | return $service; 62 | } 63 | 64 | /** 65 | * @param non-empty-string $server 66 | */ 67 | public function hasService(string $server): bool 68 | { 69 | return isset($this->services[$server]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Bootloader/QueueBootloader.php: -------------------------------------------------------------------------------- 1 | Consumer::class, 38 | JobsInterface::class => Jobs::class, 39 | PipelineRegistryInterface::class => RPCPipelineRegistry::class, 40 | PayloadDeserializerInterface::class => PayloadDeserializer::class, 41 | 42 | QueueConfig::class => static function (ConfigsInterface $configs): QueueConfig { 43 | $config = $configs->getConfig('queue'); 44 | 45 | return new QueueConfig( 46 | $config['pipelines'] ?? [], 47 | ); 48 | }, 49 | ]; 50 | } 51 | 52 | public function init(BaseQueueBootloader $bootloader, KernelInterface $kernel): void 53 | { 54 | $bootloader->registerDriverAlias(Queue::class, 'roadrunner'); 55 | /** @psalm-suppress InvalidArgument */ 56 | $kernel->addDispatcher(Dispatcher::class); 57 | } 58 | 59 | public function boot(PipelineRegistryInterface $registry): void 60 | { 61 | $registry->declareConsumerPipelines(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Bootloader/ScaffolderBootloader.php: -------------------------------------------------------------------------------- 1 | configureCommands($console); 35 | $this->configureDeclarations($scaffolder); 36 | } 37 | 38 | private function configureCommands(ConsoleBootloader $console): void 39 | { 40 | if ($this->container->has(CentrifugoApiInterface::class)) { 41 | $console->addCommand(CentrifugoHandlerCommand::class); 42 | } 43 | 44 | if ($this->container->has(Server::class)) { 45 | $console->addCommand(TcpServiceCommand::class); 46 | } 47 | } 48 | 49 | private function configureDeclarations(BaseScaffolderBootloader $scaffolder): void 50 | { 51 | $scaffolder->addDeclaration(CentrifugoHandlerDeclaration::TYPE, [ 52 | 'namespace' => 'Endpoint\\Centrifugo\\Handler', 53 | 'postfix' => 'Handler', 54 | 'class' => CentrifugoHandlerDeclaration::class, 55 | ]); 56 | 57 | $scaffolder->addDeclaration(TcpServiceDeclaration::TYPE, [ 58 | 'namespace' => 'Endpoint\\Tcp\\Service', 59 | 'postfix' => 'Service', 60 | 'class' => TcpServiceDeclaration::class, 61 | ]); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/ServiceRegistry.php: -------------------------------------------------------------------------------- 1 | */ 21 | private array $services = []; 22 | 23 | /** 24 | * @param array $services 25 | */ 26 | public function __construct( 27 | array $services, 28 | private readonly ContainerInterface $container, 29 | private readonly FactoryInterface $factory, 30 | ) { 31 | foreach ($services as $type => $service) { 32 | $this->register(RequestType::from($type), $service); 33 | } 34 | } 35 | 36 | public function register(RequestType $requestType, Autowire|ServiceInterface|string $service): void 37 | { 38 | $this->services[$requestType->value] = $service; 39 | } 40 | 41 | public function getService(RequestType $requestType): ?ServiceInterface 42 | { 43 | if (!$this->hasService($requestType)) { 44 | return null; 45 | } 46 | 47 | if (!$this->services[$requestType->value] instanceof ServiceInterface) { 48 | $this->services[$requestType->value] = $this->createService($this->services[$requestType->value]); 49 | } 50 | 51 | return $this->services[$requestType->value]; 52 | } 53 | 54 | public function hasService(RequestType $requestType): bool 55 | { 56 | return isset($this->services[$requestType->value]); 57 | } 58 | 59 | /** 60 | * @psalm-suppress LessSpecificReturnStatement 61 | */ 62 | private function createService(Autowire|ServiceInterface|string $service): ServiceInterface 63 | { 64 | return match (true) { 65 | $service instanceof ServiceInterface => $service, 66 | $service instanceof Autowire => $service->resolve($this->factory), 67 | default => $this->container->get($service), 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/GRPC/Internal/ServiceLocator.php: -------------------------------------------------------------------------------- 1 | Service Implementation 20 | * 21 | * @var array, \ReflectionClass> 22 | */ 23 | private array $registry = []; 24 | 25 | public function getServices(): array 26 | { 27 | return $this->registry; 28 | } 29 | 30 | /** 31 | * @param \ReflectionClass $class 32 | */ 33 | public function listen(\ReflectionClass $class): void 34 | { 35 | if (!$class->isInstantiable()) { 36 | return; 37 | } 38 | 39 | // Find ServiceInterface interfaces 40 | /** @var array, \ReflectionClass> $interfaces */ 41 | $interfaces = []; 42 | foreach ($class->getInterfaces() as $interface) { 43 | if (!$interface->isSubclassOf(ServiceInterface::class)) { 44 | continue; 45 | } 46 | 47 | // Deduplicate parents 48 | foreach ($interfaces as $className => $reflection) { 49 | if ($interface->isSubclassOf($className)) { 50 | continue 2; 51 | } 52 | 53 | if ($reflection->isSubclassOf($interface->getName())) { 54 | unset($interfaces[$className]); 55 | } 56 | } 57 | 58 | $interfaces[$interface->getName()] = $interface; 59 | } 60 | 61 | foreach ($interfaces as $className => $reflection) { 62 | \array_key_exists($className, $this->registry) and throw new \LogicException( 63 | \sprintf( 64 | 'Can not register service %s for interface %s because it is already registered for %s.', 65 | $class->getName(), 66 | $className, 67 | $this->registry[$className]->getName(), 68 | ), 69 | ); 70 | 71 | $this->registry[$className] = $class; 72 | } 73 | } 74 | 75 | public function finalize(): void {} 76 | } 77 | -------------------------------------------------------------------------------- /src/GRPC/Internal/ProtoCompiler.php: -------------------------------------------------------------------------------- 1 | baseNamespace = \str_replace('\\', '/', \rtrim($baseNamespace, '\\')); 27 | } 28 | 29 | /** 30 | * @throws CompileException 31 | */ 32 | public function compile(string $protoFile): array 33 | { 34 | $protoFile = \realpath($protoFile) ?: $protoFile; 35 | $tmpDir = \realpath($this->tmpDir()) ?: $this->tmpDir(); 36 | 37 | $output = $this->executor->execute( 38 | $this->commandBuilder->build(\dirname($protoFile), $tmpDir), 39 | ); 40 | 41 | if ($output !== '') { 42 | $this->files->deleteDirectory($tmpDir); 43 | throw new CompileException($output); 44 | } 45 | 46 | // copying files (using relative path and namespace) 47 | $result = []; 48 | foreach ($this->files->getFiles($tmpDir) as $file) { 49 | $result[] = $this->copy($tmpDir, $file); 50 | } 51 | 52 | $this->files->deleteDirectory($tmpDir); 53 | 54 | return $result; 55 | } 56 | 57 | private function copy(string $tmpDir, string $file): string 58 | { 59 | $source = \ltrim($this->files->relativePath($file, $tmpDir), '\\/'); 60 | if (\str_starts_with($source, $this->baseNamespace)) { 61 | $source = \ltrim(\substr($source, \strlen($this->baseNamespace)), '\\/'); 62 | } 63 | 64 | $target = $this->files->normalizePath($this->basePath . '/' . $source); 65 | 66 | $this->files->ensureDirectory(\dirname($target)); 67 | $this->files->copy($file, $target); 68 | 69 | return $target; 70 | } 71 | 72 | private function tmpDir(): string 73 | { 74 | $directory = \sys_get_temp_dir() . '/' . \spl_object_hash($this); 75 | $this->files->ensureDirectory($directory); 76 | 77 | return $this->files->normalizePath($directory, true); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Bootloader/TcpBootloader.php: -------------------------------------------------------------------------------- 1 | [self::class, 'initServiceRegistry'], 37 | Interceptor\RegistryInterface::class => [self::class, 'initInterceptorRegistry'], 38 | Server::class => Server::class, 39 | ]; 40 | } 41 | 42 | public function init(EnvironmentInterface $environment): void 43 | { 44 | $this->initTcpConfig($environment); 45 | } 46 | 47 | public function boot(KernelInterface $kernel): void 48 | { 49 | /** @psalm-suppress InvalidArgument */ 50 | $kernel->addDispatcher(Dispatcher::class); 51 | } 52 | 53 | private function initTcpConfig(EnvironmentInterface $environment): void 54 | { 55 | $this->config->setDefaults( 56 | TcpConfig::CONFIG, 57 | [ 58 | 'services' => [], 59 | 'interceptors' => [], 60 | 'debug' => $environment->get('TCP_DEBUG', false), 61 | ], 62 | ); 63 | } 64 | 65 | private function initInterceptorRegistry( 66 | TcpConfig $config, 67 | ContainerInterface $container, 68 | ): InterceptorRegistry { 69 | return new InterceptorRegistry($config->getInterceptors(), $container); 70 | } 71 | 72 | private function initServiceRegistry( 73 | TcpConfig $config, 74 | #[Proxy] ContainerInterface $container, 75 | ): Service\RegistryInterface { 76 | return new \Spiral\RoadRunnerBridge\Tcp\Internal\ServiceRegistry($config->getServices(), $container); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/GRPC/Internal/Dispatcher.php: -------------------------------------------------------------------------------- 1 | container->get(ContainerInterface::class); 38 | 39 | /** @var Server $server */ 40 | $server = $container->get(Server::class); 41 | /** @var WorkerInterface $worker */ 42 | $worker = $container->get(WorkerInterface::class); 43 | /** @var LocatorInterface $locator */ 44 | $locator = $container->get(LocatorInterface::class); 45 | 46 | foreach ($locator->getServices() as $interface => $service) { 47 | try { 48 | $server->registerService($interface, $container->get($service->getName())); 49 | } catch (\Throwable $e) { 50 | $this->handleException(new ServiceRegistrationException( 51 | "Cannot register service `{$service->getName()}`: {$e->getMessage()}", 52 | previous: $e, 53 | )); 54 | } 55 | } 56 | 57 | $server->serve( 58 | $worker, 59 | function (?\Throwable $e = null): void { 60 | if ($e !== null) { 61 | $this->handleException($e); 62 | } 63 | 64 | $this->finalizer->finalize(false); 65 | }, 66 | ); 67 | } 68 | 69 | private function handleException(\Throwable $e): void 70 | { 71 | try { 72 | $this->container->get(ExceptionReporterInterface::class)->report($e); 73 | } catch (\Throwable) { 74 | // no need to notify when unable to register an exception 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Scaffolder/Declaration/CentrifugoHandlerDeclaration.php: -------------------------------------------------------------------------------- 1 | class->setFinal(); 31 | 32 | if ($this->withApi) { 33 | $this->namespace->addUse(CentrifugoApiInterface::class); 34 | 35 | $this->class->addMethod('__construct') 36 | ->setPublic() 37 | ->addPromotedParameter('api') 38 | ->setPrivate() 39 | ->setReadOnly() 40 | ->setType(CentrifugoApiInterface::class); 41 | } 42 | } 43 | 44 | public function setType(array $data): void 45 | { 46 | $this->namespace->addUse($data['request']); 47 | $this->namespace->addUse(RequestInterface::class); 48 | $this->namespace->addUse(ServiceInterface::class); 49 | 50 | foreach ($data['use'] as $use) { 51 | $this->namespace->addUse($use); 52 | } 53 | 54 | $className = (new \ReflectionClass($data['request']))->getShortName(); 55 | 56 | $this->class 57 | ->addImplement(ServiceInterface::class) 58 | ->addMethod('handle') 59 | ->setPublic() 60 | ->setBody($data['body'] ?? '// Put your code here') 61 | ->setReturnType('void') 62 | ->setComment("\n@param {$className} \$request") 63 | ->addParameter('request') 64 | ->setType(RequestInterface::class); 65 | } 66 | 67 | public function getInstructions(): array 68 | { 69 | return [ 70 | 'Register your handler in `app/config/centrifugo.php` file. Read more in the documentation: https://spiral.dev/docs/websockets-services#service-registration', 71 | 'Read more about Centrifugo handlers: https://spiral.dev/docs/websockets-services', 72 | ]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/GRPC/Internal/Invoker.php: -------------------------------------------------------------------------------- 1 | makeInput($method, $input); 35 | $handler = $this->handler; 36 | 37 | /** @psalm-suppress InvalidArgument */ 38 | return $this->scope->runScope( 39 | new Scope('grpc-request', [UnaryCallInterface::class => new UnaryCall($ctx, $method, $message)]), 40 | static fn(): string => self::resultToString($handler->handle( 41 | new CallContext(Target::fromPair($service, $method->name), [ 42 | $ctx, 43 | $message, 44 | ]), 45 | )), 46 | ); 47 | } 48 | 49 | /** 50 | * Converts the result from the GRPC service method to the string. 51 | * 52 | * @throws InvokeException 53 | */ 54 | private static function resultToString(Message $result): string 55 | { 56 | try { 57 | return $result->serializeToString(); 58 | } catch (\Throwable $e) { 59 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e); 60 | } 61 | } 62 | 63 | /** 64 | * Converts the input from the GRPC service method to the Message object. 65 | * 66 | * @throws InvokeException 67 | */ 68 | private function makeInput(Method $method, ?string $body): Message 69 | { 70 | try { 71 | $class = $method->inputType; 72 | 73 | /** @psalm-suppress UnsafeInstantiation */ 74 | $in = new $class(); 75 | 76 | if ($body !== null) { 77 | $in->mergeFromString($body); 78 | } 79 | 80 | return $in; 81 | } catch (\Throwable $e) { 82 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/InterceptorRegistry.php: -------------------------------------------------------------------------------- 1 | > */ 26 | private array $interceptors = []; 27 | 28 | /** 29 | * @param array $interceptors 30 | */ 31 | public function __construct( 32 | array $interceptors, 33 | private readonly ContainerInterface $container, 34 | private readonly FactoryInterface $factory, 35 | ) { 36 | foreach ($interceptors as $type => $values) { 37 | if (!\is_array($values)) { 38 | $values = [$values]; 39 | } 40 | 41 | foreach ($values as $interceptor) { 42 | $this->register($type, $interceptor); 43 | } 44 | } 45 | } 46 | 47 | public function register( 48 | string $type, 49 | Autowire|CoreInterceptorInterface|InterceptorInterface|string $interceptor, 50 | ): void { 51 | if ($type !== '*' && RequestType::tryFrom($type) === null) { 52 | throw new ConfigurationException(\sprintf( 53 | 'The $type value must be one of the `%s`, `%s` values.', 54 | self::INTERCEPTORS_FOR_ALL_SERVICES, 55 | \implode('`, `', \array_map(static fn(\UnitEnum $case) => $case->value, RequestType::cases())), 56 | )); 57 | } 58 | 59 | /** @var CoreInterceptorInterface $object */ 60 | $object = match (true) { 61 | $interceptor instanceof CoreInterceptorInterface, 62 | $interceptor instanceof InterceptorInterface => $interceptor, 63 | $interceptor instanceof Autowire => $interceptor->resolve($this->factory), 64 | default => $this->container->get($interceptor), 65 | }; 66 | 67 | $this->interceptors[$type][] = $object; 68 | } 69 | 70 | public function getInterceptors(string $type): array 71 | { 72 | return \array_merge( 73 | $this->interceptors[self::INTERCEPTORS_FOR_ALL_SERVICES] ?? [], 74 | $this->interceptors[$type] ?? [], 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Config/GRPCConfig.php: -------------------------------------------------------------------------------- 1 | null, 21 | 'generatedPath' => null, 22 | 'namespace' => null, 23 | 'servicesBasePath' => null, 24 | 'services' => [], 25 | 'interceptors' => [], 26 | 'generators' => [], 27 | 'client' => [], 28 | ]; 29 | 30 | public function getClientConfig(): GrpcClientConfig 31 | { 32 | // Map Client options 33 | return match (true) { 34 | !isset($this->config['client']) => new GrpcClientConfig(), 35 | \is_array($this->config['client']) => new GrpcClientConfig( 36 | interceptors: $this->config['client']['interceptors'] ?? [], 37 | services: $this->config['client']['services'] ?? [], 38 | ), 39 | default => $this->config['client'], 40 | }; 41 | } 42 | 43 | public function getBinaryPath(): ?string 44 | { 45 | return $this->config['binaryPath'] ?? null; 46 | } 47 | 48 | /** 49 | * Path, where generated DTO files should be stored. 50 | * 51 | * @return non-empty-string|null 52 | */ 53 | public function getGeneratedPath(): ?string 54 | { 55 | return $this->config['generatedPath'] ?? null; 56 | } 57 | 58 | /** 59 | * Base namespace for generated proto files. 60 | */ 61 | public function getNamespace(): ?string 62 | { 63 | return $this->config['namespace'] ?? null; 64 | } 65 | 66 | /** 67 | * Root path for all proto files in which imports will be searched. 68 | */ 69 | public function getServicesBasePath(): ?string 70 | { 71 | return $this->config['servicesBasePath'] ?? null; 72 | } 73 | 74 | /** 75 | * @return array> 76 | */ 77 | public function getServices(): array 78 | { 79 | return (array) ($this->config['services'] ?? []); 80 | } 81 | 82 | /** 83 | * @return array|class-string> 84 | */ 85 | public function getInterceptors(): array 86 | { 87 | return (array) ($this->config['interceptors'] ?? []); 88 | } 89 | 90 | /** 91 | * @return array|GeneratorInterface> 92 | */ 93 | public function getGenerators(): array 94 | { 95 | return (array) ($this->config['generators'] ?? []); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Bootloader/CentrifugoBootloader.php: -------------------------------------------------------------------------------- 1 | [self::class, 'initServiceRegistry'], 37 | Interceptor\RegistryInterface::class => [self::class, 'initInterceptorRegistry'], 38 | CentrifugoWorkerInterface::class => CentrifugoWorker::class, 39 | ErrorHandlerInterface::class => LogErrorHandler::class, 40 | CentrifugoApiInterface::class => RPCCentrifugoApi::class, 41 | ]; 42 | } 43 | 44 | public function init(BroadcastingBootloader $broadcasting): void 45 | { 46 | $this->initConfig(); 47 | $broadcasting->registerDriverAlias(Broadcast::class, 'centrifugo'); 48 | } 49 | 50 | public function boot(AbstractKernel $kernel): void 51 | { 52 | $kernel->addDispatcher(Dispatcher::class); 53 | } 54 | 55 | private function initConfig(): void 56 | { 57 | $this->config->setDefaults( 58 | CentrifugoConfig::CONFIG, 59 | [ 60 | 'services' => [], 61 | 'interceptors' => [], 62 | ], 63 | ); 64 | } 65 | 66 | private function initInterceptorRegistry( 67 | CentrifugoConfig $config, 68 | ContainerInterface $container, 69 | FactoryInterface $factory, 70 | ): Interceptor\RegistryInterface { 71 | return new \Spiral\RoadRunnerBridge\Centrifugo\Internal\InterceptorRegistry($config->getInterceptors(), $container, $factory); 72 | } 73 | 74 | private function initServiceRegistry( 75 | CentrifugoConfig $config, 76 | #[Proxy] ContainerInterface $container, 77 | #[Proxy] FactoryInterface $factory, 78 | ): RegistryInterface { 79 | return new ServiceRegistry($config->getServices(), $container, $factory); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Http/Internal/Dispatcher.php: -------------------------------------------------------------------------------- 1 | worker->waitRequest(); 47 | if ($request === null) { 48 | // Worker got Close signal, stop serving 49 | return; 50 | } 51 | 52 | try { 53 | $response = $this->http->handle($request); 54 | $this->worker->respond($response); 55 | } catch (\Throwable $failure) { 56 | $this->worker->respond($this->errorToResponse($failure)); 57 | unset($failure); 58 | } finally { 59 | $this->finalizer->finalize(false); 60 | } 61 | } while (true); 62 | } catch (\Throwable $e) { 63 | // Don't continue if the respond() call was failed 64 | if (isset($failure)) { 65 | break; 66 | } 67 | 68 | $this->worker->respond($this->errorToResponse($e)); 69 | } 70 | } while (true); 71 | } 72 | 73 | protected function errorToResponse(\Throwable $e): ResponseInterface 74 | { 75 | $this->errorHandler->report($e); 76 | 77 | // Reporting system (non handled) exception directly to the client 78 | $response = $this->responseFactory->createResponse(500); 79 | 80 | // Add details to the response body in non-production environment 81 | $this->environment->isProduction() or $response 82 | ->getBody() 83 | ->write($this->errorHandler->render($e, Verbosity::VERBOSE)); 84 | 85 | return $response; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Centrifugo/Internal/Server.php: -------------------------------------------------------------------------------- 1 | */ 34 | private array $pipelines = []; 35 | 36 | public function __construct( 37 | private readonly InterceptorRegistry $interceptors, 38 | private readonly RegistryInterface $services, 39 | #[Proxy] private readonly ContainerInterface $container, 40 | private readonly FinalizerInterface $finalizer, 41 | private readonly ErrorHandlerInterface $errorHandler, 42 | ?PipelineBuilderInterface $pipelineBuilder = null, 43 | ) { 44 | $this->pipelineBuilder = $pipelineBuilder ?? $container->get(CompatiblePipelineBuilder::class); 45 | $this->handler = new AutowireHandler($container); 46 | } 47 | 48 | /** 49 | * @throws \JsonException 50 | */ 51 | public function serve(CentrifugoWorkerInterface $worker): void 52 | { 53 | $scope = $this->container->get(ScopeInterface::class); 54 | 55 | while ($request = $worker->waitRequest()) { 56 | try { 57 | $type = RequestType::createFrom($request); 58 | $pipeline = $this->getHandler($type); 59 | $services = $this->services; 60 | 61 | $scope->runScope( 62 | new Scope('centrifugo-request', [RequestInterface::class => $request]), 63 | static fn(): mixed => $pipeline->handle( 64 | new CallContext( 65 | /** @see ServiceInterface::handle() */ 66 | Target::fromPair($services->getService($type), 'handle'), 67 | [$request], 68 | [RequestType::class => $type], 69 | ), 70 | ), 71 | ); 72 | } catch (\Throwable $e) { 73 | $this->errorHandler->handle($request, $e); 74 | unset($e); 75 | } 76 | 77 | $this->finalizer->finalize(); 78 | } 79 | } 80 | 81 | public function getHandler(RequestType $type): HandlerInterface 82 | { 83 | /** @psalm-suppress PossiblyInvalidArgument */ 84 | return $this->pipelines[$type->value] ??= $this->pipelineBuilder 85 | ->withInterceptors(...$this->interceptors->getInterceptors($type->value)) 86 | ->build($this->handler); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spiral/roadrunner-bridge", 3 | "type": "library", 4 | "description": "RoadRunner integration package", 5 | "license": "MIT", 6 | "homepage": "https://spiral.dev", 7 | "support": { 8 | "issues": "https://github.com/spiral/roadrunner-bridge/issues", 9 | "source": "https://github.com/spiral/roadrunner-bridge", 10 | "docs": "https://spiral.dev/docs", 11 | "chat": "https://discord.gg/V6EK4he" 12 | }, 13 | "authors": [ 14 | { 15 | "name": "Anton Titov (wolfy-j)", 16 | "email": "wolfy-j@spiralscout.com" 17 | }, 18 | { 19 | "name": "Pavel Butchnev (butschster)", 20 | "email": "pavel.buchnev@spiralscout.com" 21 | }, 22 | { 23 | "name": "Aleksei Gagarin (roxblnfk)", 24 | "email": "alexey.gagarin@spiralscout.com" 25 | }, 26 | { 27 | "name": "Maksim Smakouz (msmakouz)", 28 | "email": "maksim.smakouz@spiralscout.com" 29 | } 30 | ], 31 | "funding": [ 32 | { 33 | "type": "github", 34 | "url": "https://github.com/sponsors/spiral" 35 | } 36 | ], 37 | "require": { 38 | "php": ">=8.1", 39 | "grpc/grpc": "^1.57", 40 | "psr/http-factory": "^1.1", 41 | "psr/simple-cache": "^3.0", 42 | "roadrunner-php/app-logger": "^1.0", 43 | "roadrunner-php/centrifugo": "^2.0", 44 | "roadrunner-php/lock": "^1.0", 45 | "spiral/boot": "^3.14", 46 | "spiral/grpc-client": "^1.0", 47 | "spiral/hmvc": "^3.14", 48 | "spiral/roadrunner-grpc": "^3.3", 49 | "spiral/roadrunner-http": "^3.5", 50 | "spiral/roadrunner-jobs": "^4.6.3", 51 | "spiral/roadrunner-kv": "^4.0", 52 | "spiral/roadrunner-metrics": "^3.0", 53 | "spiral/roadrunner-tcp": "^3.1 || ^4.0", 54 | "spiral/scaffolder": "^3.13", 55 | "spiral/serializer": "^3.13" 56 | }, 57 | "require-dev": { 58 | "buggregator/trap": "^1.10", 59 | "internal/dload": "^1.0.0", 60 | "phpunit/phpunit": "^10.5", 61 | "spiral/code-style": "^2.2", 62 | "spiral/framework": "^3.15.6", 63 | "spiral/nyholm-bridge": "^1.3", 64 | "spiral/roadrunner-cli": "^2.6", 65 | "spiral/testing": "^2.9.0", 66 | "vimeo/psalm": "^6.0" 67 | }, 68 | "suggest": { 69 | "ext-protobuf": "For better performance, install the protobuf C extension." 70 | }, 71 | "autoload": { 72 | "psr-4": { 73 | "Spiral\\RoadRunnerBridge\\": "src" 74 | } 75 | }, 76 | "autoload-dev": { 77 | "psr-4": { 78 | "GPBMetadata\\": "tests/generated/GPBMetadata", 79 | "Service\\": "tests/generated/Service", 80 | "Spiral\\App\\": "tests/app", 81 | "Spiral\\Tests\\": "tests/src" 82 | } 83 | }, 84 | "scripts": { 85 | "post-update-cmd": [ 86 | "dload get --no-interaction" 87 | ], 88 | "cs:diff": "php-cs-fixer fix --dry-run -v --diff", 89 | "cs:fix": "php-cs-fixer fix -v", 90 | "test": "phpunit", 91 | "psalm": "psalm", 92 | "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml" 93 | }, 94 | "config": { 95 | "sort-packages": true, 96 | "allow-plugins": { 97 | "spiral/composer-publish-plugin": true 98 | } 99 | }, 100 | "minimum-stability": "dev", 101 | "prefer-stable": true 102 | } 103 | -------------------------------------------------------------------------------- /src/Queue/Internal/Queue.php: -------------------------------------------------------------------------------- 1 | */ 28 | private array $queues = []; 29 | 30 | /** 31 | * @param non-empty-string|null $default 32 | */ 33 | public function __construct( 34 | private readonly SerializerRegistryInterface $serializer, 35 | private readonly PipelineRegistryInterface $registry, 36 | private readonly ?string $pipeline = null, 37 | private readonly ?string $default = null, 38 | ) {} 39 | 40 | /** 41 | * @param non-empty-string $name 42 | * 43 | * @return non-empty-string 44 | * @throws InvalidArgumentException 45 | * @throws JobsException 46 | * 47 | * @psalm-suppress MoreSpecificImplementedParamType 48 | */ 49 | public function push( 50 | string $name, 51 | mixed $payload = '', 52 | OptionsInterface|JobsOptionsInterface|null $options = null, 53 | ): string { 54 | $defaultPipeline = $this->pipeline ?? $this->default; 55 | 56 | $queue = $this->initQueue( 57 | $options instanceof OptionsInterface 58 | ? $options->getQueue() ?? $defaultPipeline 59 | : $defaultPipeline, 60 | ); 61 | 62 | $preparedTask = $this->createTask($queue, $name, $payload, $options); 63 | 64 | return $queue->dispatch($preparedTask)->getId(); 65 | } 66 | 67 | /** 68 | * @throws InvalidArgumentException 69 | */ 70 | private function initQueue(?string $pipeline): RRQueueInterface 71 | { 72 | if ($pipeline === null || $pipeline === '') { 73 | throw new InvalidArgumentException('You must define RoadRunner queue pipeline.'); 74 | } 75 | 76 | if (isset($this->queues[$pipeline])) { 77 | return $this->queues[$pipeline]; 78 | } 79 | 80 | return $this->queues[$pipeline] = $this->registry->getPipeline($pipeline); 81 | } 82 | 83 | /** 84 | * @param non-empty-string $name 85 | */ 86 | private function createTask( 87 | RRQueueInterface $queue, 88 | string $name, 89 | mixed $payload, 90 | OptionsInterface|JobsOptionsInterface|null $options, 91 | ): PreparedTaskInterface { 92 | $preparedTask = $queue->create( 93 | $name, 94 | $this->serializer->getSerializer($name)->serialize($payload), 95 | OptionsFactory::create($options), 96 | ); 97 | 98 | if (\is_object($payload)) { 99 | $preparedTask = $preparedTask->withHeader( 100 | self::SERIALIZED_CLASS_HEADER_KEY, 101 | $payload::class, 102 | ); 103 | } 104 | 105 | return $preparedTask; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Console/Command/Queue/ListCommand.php: -------------------------------------------------------------------------------- 1 | getIterator()); 28 | 29 | if ($queues === []) { 30 | $this->info('No job pipelines are currently declared for the RoadRunner.'); 31 | 32 | return self::SUCCESS; 33 | } 34 | 35 | $queues = \array_map(static function (QueueInterface $queue): array { 36 | \is_callable([$queue, 'getPipelineStat']) or throw new \RuntimeException( 37 | 'The queue does not support the pipeline statistics.', 38 | ); 39 | $stat = $queue->getPipelineStat(); 40 | \assert($stat !== null); 41 | 42 | $isReady = $stat->getReady(); 43 | $fontColor = $isReady ? 'green' : 'gray'; 44 | $defaultColor = $isReady ? 'default' : 'gray'; 45 | $activeFont = $isReady ? 'bold' : ''; 46 | 47 | return [ 48 | 'name' => new TableCell($stat->getPipeline(), [ 49 | 'style' => new TableCellStyle(['fg' => $fontColor, 'options' => $activeFont]), 50 | ]), 51 | 'driver' => new TableCell($stat->getDriver(), [ 52 | 'style' => new TableCellStyle( 53 | ['fg' => $defaultColor, 'options' => $activeFont], 54 | ), 55 | ]), 56 | 'priority' => new TableCell((string) $stat->getPriority(), [ 57 | 'style' => new TableCellStyle( 58 | ['fg' => $defaultColor, 'options' => $activeFont], 59 | ), 60 | ]), 61 | 'active_jobs' => new TableCell((string) $stat->getActive(), [ 62 | 'style' => new TableCellStyle( 63 | ['fg' => $stat->getActive() > 0 ? 'green' : $defaultColor, 'options' => $activeFont], 64 | ), 65 | ]), 66 | 'delayed_jobs' => new TableCell((string) $stat->getDelayed(), [ 67 | 'style' => new TableCellStyle( 68 | ['fg' => $stat->getDelayed() > 0 ? 'green' : $defaultColor, 'options' => $activeFont], 69 | ), 70 | ]), 71 | 'reserved_jobs' => new TableCell((string) $stat->getReserved(), [ 72 | 'style' => new TableCellStyle( 73 | ['fg' => $stat->getReserved() > 0 ? 'green' : $defaultColor, 'options' => $activeFont], 74 | ), 75 | ]), 76 | 'is_active' => $stat->getReady() ? ' ✓ ' : ' ✖ ', 77 | ]; 78 | }, $queues); 79 | 80 | \ksort($queues); 81 | \assert($this->output !== null); 82 | $table = new Table($this->output); 83 | 84 | $table->setHeaders( 85 | ['Name', 'Driver', 'Priority', 'Active jobs', 'Delayed jobs', 'Reserved jobs', 'Is active',], 86 | ); 87 | 88 | foreach ($queues as $data) { 89 | $table->addRow($data); 90 | } 91 | 92 | $table->render(); 93 | 94 | return self::SUCCESS; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Queue/Internal/PayloadDeserializer.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private array $handlerTypes = []; 24 | 25 | public function __construct( 26 | private readonly HandlerRegistryInterface $registry, 27 | private readonly SerializerRegistryInterface $serializer, 28 | ) {} 29 | 30 | public function deserialize(ReceivedTaskInterface $task): mixed 31 | { 32 | $payload = $task->getPayload(); 33 | 34 | $serializer = $this->serializer->getSerializer($name = $task->getName()); 35 | 36 | $class = $this->getClassFromHeader($task); 37 | if ($class !== null && \class_exists($class)) { 38 | return $serializer->unserialize($payload, $class); 39 | } 40 | 41 | try { 42 | $class = $this->detectTypeFromJobHandler($this->registry->getHandler($name)); 43 | } catch (\Throwable) { 44 | } 45 | 46 | if ($class === 'string') { 47 | return $payload; 48 | } 49 | 50 | if ($class !== null && \class_exists($class)) { 51 | return $serializer->unserialize($payload, $class); 52 | } 53 | 54 | return $serializer->unserialize($payload); 55 | } 56 | 57 | private function getClassFromHeader(ReceivedTaskInterface $task): ?string 58 | { 59 | if ($task->hasHeader(Queue::SERIALIZED_CLASS_HEADER_KEY)) { 60 | return $task->getHeaderLine(Queue::SERIALIZED_CLASS_HEADER_KEY); 61 | } 62 | 63 | return null; 64 | } 65 | 66 | /** 67 | * Detects the type of for payload argument of the given handler's method. 68 | * 69 | * @return class-string|string|null 70 | * @throws \ReflectionException 71 | * 72 | */ 73 | private function detectTypeFromJobHandler(HandlerInterface $handler): ?string 74 | { 75 | $handler = new \ReflectionClass($handler); 76 | 77 | if (isset($this->handlerTypes[$handler->getName()])) { 78 | return $this->handlerTypes[$handler->getName()]; 79 | } 80 | 81 | if (!$handler->hasMethod('invoke')) { 82 | return $this->handlerTypes[$handler->getName()] = null; 83 | } 84 | 85 | $handlerMethod = $handler->getMethod('invoke'); 86 | 87 | foreach ($handlerMethod->getParameters() as $parameter) { 88 | if ($parameter->getName() !== 'payload') { 89 | continue; 90 | } 91 | 92 | $type = $this->detectType($parameter->getType()); 93 | if ($type !== null) { 94 | return $this->handlerTypes[$handler->getName()] = $type; 95 | } 96 | } 97 | 98 | return $this->handlerTypes[$handler->getName()] = null; 99 | } 100 | 101 | /** 102 | * Detects the type of the given parameter. 103 | * 104 | * @throws \ReflectionException 105 | */ 106 | private function detectType(\ReflectionType|null $type): ?string 107 | { 108 | if ($type instanceof \ReflectionNamedType) { 109 | if ($type->isBuiltin()) { 110 | return $type->getName() === 'string' ? 'string' : null; 111 | } 112 | 113 | return $type->getName(); 114 | } 115 | 116 | if ($type instanceof \ReflectionUnionType) { 117 | foreach ($type->getTypes() as $t) { 118 | $class = $this->detectType($t); 119 | if ($class !== null) { 120 | return $class; 121 | } 122 | } 123 | } 124 | 125 | return null; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Queue/Internal/Dispatcher.php: -------------------------------------------------------------------------------- 1 | handler; 56 | 57 | while ($task = $this->consumer->waitTask()) { 58 | try { 59 | /** @psalm-suppress InvalidArgument */ 60 | $this->scope->runScope( 61 | new Scope('queue-task', [TaskInterface::class => new Task( 62 | id: $task->getId(), 63 | queue: $task->getQueue(), 64 | name: $task->getName(), 65 | payload: $this->deserializer->deserialize($task), 66 | headers: $task->getHeaders(), 67 | )]), 68 | static function (TaskInterface $queueTask) use ($handler, $task): void { 69 | $handler->handle( 70 | name: $queueTask->getName(), 71 | driver: 'roadrunner', 72 | queue: $queueTask->getQueue(), 73 | id: $queueTask->getId(), 74 | payload: $queueTask->getPayload(), 75 | headers: $queueTask->getHeaders(), 76 | ); 77 | 78 | $task->ack(); 79 | }, 80 | ); 81 | } catch (RetryException $e) { 82 | $this->reporter->report($e); 83 | $this->retry($e, $task); 84 | unset($e); 85 | } catch (\Throwable $e) { 86 | $this->reporter->report($e); 87 | $task->nack($e); 88 | unset($e); 89 | } 90 | 91 | $this->finalizer->finalize(terminate: false); 92 | } 93 | } 94 | 95 | /** 96 | * @throws JobsException 97 | */ 98 | private function retry(RetryException $e, ReceivedTaskInterface $task): void 99 | { 100 | $options = $e->getOptions(); 101 | 102 | if ($options instanceof ProvidesHeadersInterface || $options instanceof ExtendedOptionsInterface) { 103 | /** @var array|non-empty-string $values */ 104 | foreach ($options->getHeaders() as $header => $values) { 105 | $task = $task->withHeader($header, $values); 106 | } 107 | } 108 | 109 | if ($options instanceof OptionsInterface) { 110 | $delay = $options->getDelay(); 111 | $delay === null or $task = $task->withDelay($delay); 112 | } 113 | 114 | $task->requeue($e); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Tcp/Internal/Server.php: -------------------------------------------------------------------------------- 1 | */ 36 | private array $pipelines = []; 37 | 38 | public function __construct( 39 | private readonly TcpConfig $config, 40 | private readonly InterceptorRegistry $interceptors, 41 | private readonly ServiceRegistry $services, 42 | #[Proxy] private readonly ContainerInterface $container, 43 | private readonly FinalizerInterface $finalizer, 44 | private readonly ExceptionReporterInterface $reporter, 45 | ?PipelineBuilderInterface $pipelineBuilder = null, 46 | ) { 47 | $this->pipelineBuilder = $pipelineBuilder ?? $container->get(CompatiblePipelineBuilder::class); 48 | $this->handler = new AutowireHandler($container); 49 | } 50 | 51 | /** 52 | * @throws \JsonException 53 | */ 54 | public function serve(?WorkerInterface $worker = null): void 55 | { 56 | $worker ??= Worker::create(); 57 | $tcpWorker = new TcpWorker($worker); 58 | $scope = $this->container->get(ScopeInterface::class); 59 | 60 | while ($request = $tcpWorker->waitRequest()) { 61 | $e = null; 62 | try { 63 | $server = $request->getServer(); 64 | $pipeline = $this->getPipeline($server); 65 | $services = $this->services; 66 | 67 | /** 68 | * @var ResponseInterface $response 69 | */ 70 | $response = $scope->runScope( 71 | new Scope('tcp-request', [RequestInterface::class => $request]), 72 | static fn(): mixed => $pipeline->handle(new CallContext( 73 | /** @see \Spiral\RoadRunnerBridge\Tcp\Service\ServiceInterface::handle() */ 74 | Target::fromPair($services->getService($server), 'handle'), 75 | [$request], 76 | ['server' => $server], 77 | )), 78 | ); 79 | } catch (\Throwable $e) { 80 | $worker->error($this->config->isDebugMode() ? (string) $e : $e->getMessage()); 81 | $response = new CloseConnection(); 82 | } finally { 83 | if (isset($response) && $response instanceof ResponseInterface) { 84 | $tcpWorker->getWorker()->respond( 85 | new Payload($response->getBody(), $response->getAction()->value), 86 | ); 87 | } 88 | 89 | $this->finalize($e); 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * @param non-empty-string $server 96 | */ 97 | private function getPipeline(string $server): HandlerInterface 98 | { 99 | return $this->pipelines[$server] ??= $this->pipelineBuilder 100 | ->withInterceptors(...$this->interceptors->getInterceptors($server)) 101 | ->build($this->handler); 102 | } 103 | 104 | private function finalize(?\Throwable $e): void 105 | { 106 | if ($e !== null) { 107 | try { 108 | $this->reporter->report($e); 109 | } catch (\Throwable) { 110 | // no need to notify when unable to register an exception 111 | } 112 | } 113 | 114 | $this->finalizer->finalize(terminate: false); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Console/Command/Scaffolder/CentrifugoHandlerCommand.php: -------------------------------------------------------------------------------- 1 | createDeclaration(CentrifugoHandlerDeclaration::class, [ 36 | 'withApi' => $this->withApi, 37 | ]); 38 | 39 | $type = match ($this->type) { 40 | 'connect' => [ 41 | 'request' => \RoadRunner\Centrifugo\Request\Connect::class, 42 | 'use' => [ 43 | \RoadRunner\Centrifugo\Payload\ConnectResponse::class, 44 | ], 45 | 'body' => $this->createConnectHandlerBody(), 46 | ], 47 | 'subscribe' => [ 48 | 'request' => \RoadRunner\Centrifugo\Request\Subscribe::class, 49 | 'use' => [ 50 | \RoadRunner\Centrifugo\Payload\SubscribeResponse::class, 51 | ], 52 | 'body' => $this->createSubscribeHandlerBody(), 53 | ], 54 | 'rpc' => [ 55 | 'request' => \RoadRunner\Centrifugo\Request\RPC::class, 56 | 'use' => [ 57 | \RoadRunner\Centrifugo\Payload\RPCResponse::class, 58 | ], 59 | 'body' => $this->createRpcHandlerBody(), 60 | ], 61 | 'refresh' => [ 62 | 'request' => \RoadRunner\Centrifugo\Request\Refresh::class, 63 | 'use' => [ 64 | \RoadRunner\Centrifugo\Payload\RefreshResponse::class, 65 | ], 66 | 'body' => $this->createRefreshHandlerBody(), 67 | ], 68 | 'publish' => [ 69 | 'request' => \RoadRunner\Centrifugo\Request\Publish::class, 70 | 'use' => [ 71 | \RoadRunner\Centrifugo\Payload\PublishResponse::class, 72 | ], 73 | 'body' => $this->createPublishHandlerBody(), 74 | ], 75 | default => throw new \InvalidArgumentException('Invalid service type'), 76 | }; 77 | 78 | $declaration->setType($type); 79 | 80 | $this->writeDeclaration($declaration); 81 | 82 | return self::SUCCESS; 83 | } 84 | 85 | private function createConnectHandlerBody(): string 86 | { 87 | return <<<'PHP' 88 | try { 89 | $request->respond( 90 | new ConnectResponse( 91 | user: '', // User ID 92 | channels: [ 93 | // List of channels to subscribe to on connect to Centrifugo 94 | // 'public', 95 | ], 96 | ) 97 | ); 98 | } catch (\Throwable $e) { 99 | $request->error($e->getCode(), $e->getMessage()); 100 | } 101 | PHP; 102 | } 103 | 104 | private function createSubscribeHandlerBody(): string 105 | { 106 | return <<<'PHP' 107 | try { 108 | // Here you can check if user is allowed to subscribe to requested channel 109 | if ($request->channel !== 'public') { 110 | $request->disconnect('403', 'Channel is not allowed.'); 111 | return; 112 | } 113 | 114 | $request->respond( 115 | new SubscribeResponse() 116 | ); 117 | } catch (\Throwable $e) { 118 | $request->error($e->getCode(), $e->getMessage()); 119 | } 120 | PHP; 121 | } 122 | 123 | private function createRpcHandlerBody(): string 124 | { 125 | return <<<'PHP' 126 | $result = match ($request->method) { 127 | 'ping' => ['pong' => 'pong', 'code' => 200], 128 | default => ['error' => 'Not found', 'code' => 404] 129 | }; 130 | 131 | try { 132 | $request->respond( 133 | new RPCResponse( 134 | data: $result 135 | ) 136 | ); 137 | } catch (\Throwable $e) { 138 | $request->error($e->getCode(), $e->getMessage()); 139 | } 140 | PHP; 141 | } 142 | 143 | private function createRefreshHandlerBody(): string 144 | { 145 | return <<<'PHP' 146 | try { 147 | $request->respond( 148 | new RefreshResponse(...) 149 | ); 150 | } catch (\Throwable $e) { 151 | $request->error($e->getCode(), $e->getMessage()); 152 | } 153 | PHP; 154 | } 155 | 156 | private function createPublishHandlerBody(): string 157 | { 158 | return <<<'PHP' 159 | try { 160 | $request->respond( 161 | new PublishResponse(...) 162 | ); 163 | } catch (\Throwable $e) { 164 | $request->error($e->getCode(), $e->getMessage()); 165 | } 166 | PHP; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Queue/Internal/RPCPipelineRegistry.php: -------------------------------------------------------------------------------- 1 | */ 30 | private readonly array $pipelines; 31 | 32 | /** 33 | * @param Jobs|JobsInterface $jobs 34 | * @param int $ttl Time to cache existing RoadRunner pipelines 35 | */ 36 | public function __construct( 37 | private readonly LoggerInterface $logger, 38 | private readonly JobsInterface $jobs, 39 | private readonly RoadRunnerMode $mode, 40 | QueueConfig $config, 41 | private readonly int $ttl = 60, 42 | ) { 43 | $this->pipelines = $config->getPipelines(); 44 | } 45 | 46 | /** 47 | * @throws JobsException 48 | * @throws InvalidArgumentException 49 | */ 50 | public function declareConsumerPipelines(): void 51 | { 52 | if ($this->mode !== RoadRunnerMode::Jobs) { 53 | return; 54 | } 55 | 56 | $this->expiresAt = 0; 57 | 58 | foreach ($this->pipelines as $name => $pipeline) { 59 | $consume = $pipeline['consume'] ?? false; 60 | if (!$consume) { 61 | continue; 62 | } 63 | 64 | $connector = $this->getConnector($name); 65 | 66 | if (!$this->isExists($connector)) { 67 | try { 68 | $this->jobs->create($connector)->resume(); 69 | } catch (JobsException $e) { 70 | $this->logger->warning( 71 | \sprintf( 72 | 'Unable to declare consumer pipeline "%s". Reason: %s', 73 | $name, 74 | $e->getMessage(), 75 | ), 76 | ); 77 | } 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * @throws JobsException 84 | * @throws InvalidArgumentException 85 | */ 86 | public function getPipeline(string $name): QueueInterface 87 | { 88 | if (!isset($this->pipelines[$name])) { 89 | return $this->jobs->connect($name); 90 | } 91 | 92 | $connector = $this->getConnector($name); 93 | 94 | /** 95 | * @var OptionsInterface|null $options 96 | */ 97 | $options = OptionsFactory::create($this->pipelines[$name]['options'] ?? null) 98 | ?? OptionsFactory::fromCreateInfo($connector); 99 | 100 | \assert($options === null || $options instanceof OptionsInterface); 101 | 102 | if (!$this->isExists($connector)) { 103 | return $this->create($connector, $options); 104 | } 105 | 106 | return $this->connect($connector, $options); 107 | } 108 | 109 | /** 110 | * @param non-empty-string $name 111 | * 112 | * @throws InvalidArgumentException 113 | */ 114 | public function getConnector(string $name): CreateInfoInterface 115 | { 116 | // Connector is required for pipeline declaration 117 | if (!isset($this->pipelines[$name]['connector'])) { 118 | throw new InvalidArgumentException( 119 | \sprintf('You must specify connector for given pipeline `%s`.', $name), 120 | ); 121 | } 122 | 123 | if (!$this->pipelines[$name]['connector'] instanceof CreateInfoInterface) { 124 | throw new InvalidArgumentException( 125 | \sprintf('Connector should implement %s interface.', CreateInfoInterface::class), 126 | ); 127 | } 128 | 129 | return $this->pipelines[$name]['connector']; 130 | } 131 | 132 | /** 133 | * Check if RoadRunner jobs pipeline exists 134 | */ 135 | private function isExists(CreateInfoInterface $connector): bool 136 | { 137 | if ($this->expiresAt < \time()) { 138 | $this->existPipelines = \array_keys( 139 | \iterator_to_array($this->jobs->getIterator()), 140 | ); 141 | 142 | $this->expiresAt = \time() + $this->ttl; 143 | } 144 | 145 | return \in_array($connector->getName(), $this->existPipelines, true); 146 | } 147 | 148 | /** 149 | * Create a new RoadRunner jobs pipeline 150 | * 151 | * @throws JobsException 152 | */ 153 | private function create(CreateInfoInterface $connector, ?OptionsInterface $options = null): QueueInterface 154 | { 155 | $this->expiresAt = 0; 156 | /** @psalm-suppress TooManyArguments */ 157 | return $this->jobs->create($connector, $options); 158 | } 159 | 160 | /** 161 | * Connect to the RoadRunner jobs pipeline 162 | */ 163 | private function connect(CreateInfoInterface $connector, ?OptionsInterface $options = null): QueueInterface 164 | { 165 | /** @psalm-suppress TooManyArguments */ 166 | return $this->jobs->connect($connector->getName(), $options); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Console/Command/GRPC/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | getBinaryPath(); 36 | 37 | if ($binaryPath === null && \file_exists($this->getDefaultBinary())) { 38 | $binaryPath = $this->getDefaultBinary(); 39 | } 40 | 41 | $binaryPath ?? throw new \RuntimeException('Protoc plugin binary was not set in the configuration.'); 42 | 43 | if (!\file_exists($binaryPath)) { 44 | $this->error("Protoc plugin binary `$binaryPath` was not found. Use command `./vendor/bin/rr download-protoc-binary` to download it."); 45 | 46 | return self::FAILURE; 47 | } 48 | 49 | \assert($binaryPath !== null); 50 | 51 | $compiler = new ProtoCompiler( 52 | $this->getPath($kernel, $config->getGeneratedPath()), 53 | $this->getNamespace($kernel, $config->getNamespace()), 54 | $files, 55 | new ProtocCommandBuilder($files, $config, $binaryPath), 56 | new CommandExecutor(), 57 | ); 58 | 59 | $compiled = []; 60 | foreach ($repository->getProtos() as $protoFile) { 61 | if (!\file_exists($protoFile)) { 62 | $this->sprintf('Proto file `%s` not found.', $protoFile); 63 | continue; 64 | } 65 | $protoFile = \realpath($protoFile) ?: $protoFile; 66 | 67 | $this->sprintf("Compiling `%s`:\n", $protoFile); 68 | 69 | try { 70 | $result = $compiler->compile($protoFile); 71 | } catch (CompileException $e) { 72 | throw $e; 73 | } catch (\Throwable $e) { 74 | $this->sprintf("Error: %s\n", $e->getMessage()); 75 | continue; 76 | } 77 | 78 | if ($result === []) { 79 | $this->sprintf("No files were generated for `%s`.\n", $protoFile); 80 | continue; 81 | } 82 | 83 | foreach ($result as $file) { 84 | $this->sprintf( 85 | " %s%s%s\n", 86 | Color::LIGHT_WHITE, 87 | $files->relativePath($file, $dirs->get('root')), 88 | Color::RESET, 89 | ); 90 | 91 | $compiled[] = $file; 92 | } 93 | } 94 | 95 | foreach ($generatorRegistry->getGenerators() as $generator) { 96 | /** @psalm-suppress ArgumentTypeCoercion */ 97 | $generator->run( 98 | $compiled, 99 | $this->getPath($kernel, $config->getGeneratedPath()), 100 | $this->getNamespace($kernel, $config->getNamespace()), 101 | ); 102 | } 103 | 104 | return self::SUCCESS; 105 | } 106 | 107 | /** 108 | * Get or detect base source code path. By default fallbacks to kernel location. 109 | * 110 | * @param non-empty-string|null $generatedPath 111 | * 112 | * @return non-empty-string 113 | */ 114 | protected function getPath(KernelInterface $kernel, ?string $generatedPath): string 115 | { 116 | $path = $this->argument('path'); 117 | if ($path !== 'auto') { 118 | return $path; 119 | } 120 | 121 | if ($generatedPath !== null) { 122 | return $generatedPath; 123 | } 124 | 125 | $r = new \ReflectionObject($kernel); 126 | 127 | /** @psalm-suppress LessSpecificReturnStatement */ 128 | return \dirname($r->getFileName()); 129 | } 130 | 131 | /** 132 | * Get or detect base namespace. By default fallbacks to kernel namespace. 133 | * 134 | * @return non-empty-string 135 | * 136 | * @psalm-suppress LessSpecificReturnStatement 137 | */ 138 | protected function getNamespace(KernelInterface $kernel, ?string $protoNamespace): string 139 | { 140 | $namespace = $this->argument('namespace'); 141 | if ($namespace !== 'auto') { 142 | return $namespace; 143 | } 144 | 145 | if ($protoNamespace !== null) { 146 | return $protoNamespace; 147 | } 148 | 149 | return (new \ReflectionObject($kernel))->getNamespaceName(); 150 | } 151 | 152 | private function getDefaultBinary(): string 153 | { 154 | return 'protoc-gen-php-grpc' . (PHP_OS_FAMILY === 'Windows' ? '.exe' : ''); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Logger/Internal/RoadRunnerJsonFormatter.php: -------------------------------------------------------------------------------- 1 | , 68 | * extra?: array 69 | * } $record The log record to format, either as a LogRecord object or array 70 | * 71 | * @return array{ 72 | * level: non-empty-string, 73 | * ts: non-empty-string, 74 | * logger: non-empty-string, 75 | * msg: string, 76 | * ... 77 | * } The formatted log record as an array with the following structure: 78 | * - level: The log level (string) 79 | * - ts: Timestamp in the configured format 80 | * - logger: Logger name with optional prefix 81 | * - msg: The log message 82 | * - Additional context and extra data merged from the original record 83 | * 84 | * @throws \InvalidArgumentException If the record structure is invalid 85 | */ 86 | public function format(array|LogRecord $record): array 87 | { 88 | $normalized = $this->normalize(\is_array($record) ? $record : $record->toArray()); 89 | 90 | $level = match (Logger::toMonologLevel($record['level'])) { 91 | Level::Error, Level::Critical, Level::Alert, Level::Emergency => 'error', 92 | Level::Warning => 'warning', 93 | Level::Info, Level::Notice => 'info', 94 | Level::Debug => 'debug', 95 | }; 96 | 97 | \assert($record['datetime'] instanceof \DateTimeInterface); 98 | 99 | $ts = $this->loggerMode === RoadRunnerLogsMode::Development 100 | ? $record['datetime']->format(\DateTimeInterface::RFC3339) 101 | : $record['datetime']->format('Uu000'); 102 | 103 | \assert(\is_string($record['channel'])); 104 | 105 | return [ 106 | 'level' => $this->loggerMode === RoadRunnerLogsMode::Development ? \strtoupper($level) : $level, 107 | 'ts' => $ts, 108 | 'logger' => $this->loggerPrefix . $record['channel'], 109 | 'msg' => $record['message'], 110 | ] 111 | + ($normalized['context'] ?? []) 112 | + ($normalized['extra'] ?? []); 113 | } 114 | 115 | /** 116 | * Normalize exception data with configurable trace depth limit. 117 | * 118 | * This method overrides the parent implementation to limit the number of stack trace 119 | * entries included in the log output. This prevents excessive log size while still 120 | * providing useful debugging information. 121 | * 122 | * The trace count limit is controlled by the `$traceCount` constructor parameter, 123 | * which defaults to 5 entries. 124 | * 125 | * @param \Throwable $e The exception to normalize 126 | * @param int $depth The current recursion depth (used by parent implementation) 127 | * 128 | * @return array>> The normalized exception data with limited stack trace 129 | */ 130 | protected function normalizeException(\Throwable $e, int $depth = 0): array 131 | { 132 | $normalized = parent::normalizeException($e, $depth); 133 | 134 | if (isset($normalized['trace']) && \is_array($normalized['trace'])) { 135 | $normalized['trace'] = \array_slice($normalized['trace'], 0, $this->traceCount); 136 | } 137 | 138 | return $normalized; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Bootloader/GRPCBootloader.php: -------------------------------------------------------------------------------- 1 | Server::class, 54 | InvokerInterface::class => [self::class, 'initInvoker'], 55 | LocatorInterface::class => ServiceLocator::class, 56 | ProtoFilesRepositoryInterface::class => [self::class, 'initProtoFilesRepository'], 57 | GeneratorRegistryInterface::class => [self::class, 'initGeneratorRegistry'], 58 | GrpcClientConfig::class => [GRPCConfig::class, 'getClientConfig'], 59 | ]; 60 | } 61 | 62 | public function init( 63 | TokenizerListenerRegistryInterface $listenerRegistry, 64 | LocatorInterface $listener, 65 | ): void { 66 | $this->initGrpcConfig(); 67 | $listenerRegistry->addListener($listener); 68 | } 69 | 70 | public function boot(KernelInterface $kernel): void 71 | { 72 | /** @psalm-suppress InvalidArgument */ 73 | $kernel->addDispatcher(Dispatcher::class); 74 | } 75 | 76 | /** 77 | * @param Autowire|class-string|CoreInterceptorInterface $interceptor 78 | */ 79 | public function addInterceptor(string|CoreInterceptorInterface|Autowire $interceptor): void 80 | { 81 | $this->config->modify( 82 | GRPCConfig::CONFIG, 83 | new Append('interceptors', null, $interceptor), 84 | ); 85 | } 86 | 87 | /** 88 | * @param Autowire|class-string|GeneratorInterface $generator 89 | */ 90 | public function addGenerator(string|GeneratorInterface|Autowire $generator): void 91 | { 92 | $this->config->modify(GRPCConfig::CONFIG, new Append('generators', null, $generator)); 93 | } 94 | 95 | private function initGrpcConfig(): void 96 | { 97 | $this->config->setDefaults( 98 | GRPCConfig::CONFIG, 99 | [ 100 | /** 101 | * Path to protoc-gen-php-grpc library. 102 | */ 103 | 'binaryPath' => null, 104 | 'generatedPath' => null, 105 | 'namespace' => null, 106 | 'servicesBasePath' => null, 107 | 'services' => [], 108 | 'interceptors' => [], 109 | 'generators' => [], 110 | 'client' => [ 111 | 'interceptors' => [], 112 | ], 113 | ], 114 | ); 115 | } 116 | 117 | /** 118 | * @psalm-suppress DeprecatedInterface 119 | */ 120 | private function initInvoker( 121 | GRPCConfig $config, 122 | #[Proxy] ContainerInterface $container, 123 | FactoryInterface $factory, 124 | ?PipelineBuilderInterface $pipelineBuilder = null, 125 | ): InvokerInterface { 126 | /** @var PipelineBuilderInterface $pipelineBuilder */ 127 | $pipelineBuilder ??= $container->get(CompatiblePipelineBuilder::class); 128 | 129 | $handler = new AutowireHandler($container, false); 130 | 131 | /** 132 | * @var list $list 133 | * @var ContainerInterface $c 134 | * @var FactoryInterface $f 135 | */ 136 | $list = []; 137 | $c = $container->get(ContainerInterface::class); 138 | $f = $c->get(FactoryInterface::class); 139 | foreach ($config->getInterceptors() as $interceptor) { 140 | $list[] = $this->resolve($interceptor, $c, $factory); 141 | } 142 | 143 | return $f->make(Invoker::class, [ 144 | 'handler' => $pipelineBuilder->withInterceptors(...$list)->build($handler), 145 | ]); 146 | } 147 | 148 | private function initProtoFilesRepository(GRPCConfig $config): ProtoFilesRepositoryInterface 149 | { 150 | return new FileRepository($config->getServices()); 151 | } 152 | 153 | private function initGeneratorRegistry( 154 | GRPCConfig $config, 155 | ContainerInterface $container, 156 | FactoryInterface $factory, 157 | ): GeneratorRegistryInterface { 158 | $registry = new GeneratorRegistry(); 159 | foreach ($config->getGenerators() as $generator) { 160 | $generator = $this->resolve($generator, $container, $factory); 161 | 162 | \assert($generator instanceof GeneratorInterface); 163 | 164 | $registry->addGenerator($generator); 165 | } 166 | 167 | return $registry; 168 | } 169 | 170 | private function resolve(mixed $dependency, ContainerInterface $container, FactoryInterface $factory): object 171 | { 172 | return match (true) { 173 | \is_string($dependency) => $container->get($dependency), 174 | $dependency instanceof Autowire => $dependency->resolve($factory), 175 | default => $dependency, 176 | }; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RoadRunner bridge to Spiral Framework 2 | 3 | [![PHP Version Require](https://poser.pugx.org/spiral/roadrunner-bridge/require/php)](https://packagist.org/packages/spiral/roadrunner-bridge) 4 | [![Latest Stable Version](https://poser.pugx.org/spiral/roadrunner-bridge/v/stable)](https://packagist.org/packages/spiral/roadrunner-bridge) 5 | [![phpunit](https://github.com/spiral/roadrunner-bridge/actions/workflows/phpunit.yml/badge.svg)](https://github.com/spiral/roadrunner-bridge/actions) 6 | [![psalm](https://github.com/spiral/roadrunner-bridge/actions/workflows/psalm.yml/badge.svg)](https://github.com/spiral/roadrunner-bridge/actions) 7 | [![Codecov](https://codecov.io/gh/spiral/roadrunner-bridge/branch/master/graph/badge.svg)](https://codecov.io/gh/spiral/roadrunner-bridge/) 8 | [![Total Downloads](https://poser.pugx.org/spiral/roadrunner-bridge/downloads)](https://packagist.org/packages/spiral/roadrunner-bridge) 9 | 10 | 11 | ## Requirements 12 | 13 | Make sure that your server is configured with following PHP version and extensions: 14 | 15 | - PHP 8.1+ 16 | - Spiral Framework 3.14+ 17 | - Extension `protobuf` (recommended) 18 | 19 | ## Installation 20 | 21 | To install the package: 22 | 23 | ```bash 24 | composer require spiral/roadrunner-bridge 25 | ``` 26 | 27 | After package install you need to add bootloaders from the package in your application on the top of the list. 28 | 29 | ```php 30 | use Spiral\RoadRunnerBridge\Bootloader as RoadRunnerBridge; 31 | 32 | protected const LOAD = [ 33 | RoadRunnerBridge\HttpBootloader::class, // Optional, if it needs to work with http plugin 34 | RoadRunnerBridge\QueueBootloader::class, // Optional, if it needs to work with jobs plugin 35 | RoadRunnerBridge\CacheBootloader::class, // Optional, if it needs to work with KV plugin 36 | RoadRunnerBridge\GRPCBootloader::class, // Optional, if it needs to work with GRPC plugin 37 | RoadRunnerBridge\CentrifugoBootloader::class, // Optional, if it needs to work with centrifugo server 38 | RoadRunnerBridge\TcpBootloader::class, // Optional, if it needs to work with TCP plugin 39 | RoadRunnerBridge\MetricsBootloader::class, // Optional, if it needs to work with metrics plugin 40 | RoadRunnerBridge\LoggerBootloader::class, // Optional, if it needs to work with app-logger plugin 41 | RoadRunnerBridge\LockBootloader::class, // Optional, if it needs to work with lock plugin 42 | RoadRunnerBridge\ScaffolderBootloader::class, // Optional, to generate Centrifugo handlers and TCP services via Scaffolder 43 | RoadRunnerBridge\CommandBootloader::class, 44 | // ... 45 | ]; 46 | ``` 47 | 48 | ## Usage 49 | 50 | - [Cache](https://spiral.dev/docs/basics-cache) 51 | - [Queue](https://spiral.dev/docs/queue-configuration) 52 | - [GRPC](https://spiral.dev/docs/grpc-configuration) 53 | - [Websockets](https://spiral.dev/docs/websockets-configuration) 54 | - [Logger](https://spiral.dev/docs/basics-logging/#roadrunner-handler) 55 | - [Metrics](https://spiral.dev/docs/advanced-prometheus-metrics) 56 | - [TCP](#tcp) 57 | - [Configuration](#configuration-2) 58 | - [Services](#services) 59 | 60 | ### TCP 61 | 62 | RoadRunner includes TCP server and can be used to replace classic TCP setup with much greater performance and 63 | flexibility. 64 | 65 | #### Bootloader 66 | 67 | Add `Spiral\RoadRunnerBridge\Bootloader\TcpBootloader` to application bootloaders list: 68 | 69 | ```php 70 | use Spiral\RoadRunnerBridge\Bootloader as RoadRunnerBridge; 71 | 72 | protected const LOAD = [ 73 | // ... 74 | RoadRunnerBridge\TcpBootloader::class, 75 | // ... 76 | ]; 77 | ``` 78 | 79 | This bootloader adds a dispatcher and necessary services for TCP to work. 80 | Also, using the `addService` and `addInterceptors` methods can dynamically add services to TCP servers and configure 81 | interceptors. 82 | 83 | #### Configuration 84 | 85 | Configure `tcp` section in the RoadRunner `.rr.yaml` configuration file with needed TCP servers. Example: 86 | 87 | ```yaml 88 | tcp: 89 | servers: 90 | smtp: 91 | addr: tcp://127.0.0.1:22 92 | delimiter: "\r\n" # by default 93 | monolog: 94 | addr: tcp://127.0.0.1:9913 95 | 96 | pool: 97 | num_workers: 2 98 | max_jobs: 0 99 | allocate_timeout: 60s 100 | destroy_timeout: 60s 101 | ``` 102 | 103 | Create configuration file `app/config/tcp.php`. In the configuration, it's required to specify the services that 104 | will handle requests from a specific TCP server. Optionally, interceptors can be added for each specific server. 105 | With the help there, can add some logic before handling the request in service. Configuration example: 106 | 107 | ```php 108 | [ 117 | 'smtp' => SomeService::class, 118 | 'monolog' => OtherService::class 119 | ], 120 | 121 | /** 122 | * Interceptors, this section is optional. 123 | * @see https://spiral.dev/docs/cookbook-domain-core/2.8/en#core-interceptors 124 | */ 125 | 'interceptors' => [ 126 | // several interceptors 127 | 'smtp' => [ 128 | SomeInterceptor::class, 129 | OtherInterceptor::class 130 | ], 131 | 'monolog' => SomeInterceptor::class // one interceptor 132 | ], 133 | 134 | 'debug' => env('TCP_DEBUG', false) 135 | ]; 136 | ``` 137 | 138 | #### Services 139 | 140 | A service must implement the interface `Spiral\RoadRunnerBridge\Tcp\Service\ServiceInterface` with one required 141 | method `handle`. 142 | After processing a request, the `handle` method must return the `Spiral\RoadRunnerBridge\Tcp\Response\ResponseInterface` 143 | object 144 | with result (`RespondMessage`, `CloseConnection`, `ContinueRead`). 145 | 146 | Example: 147 | 148 | ```php 149 | **Note** 181 | > Namespace (and generation path) can be configured. 182 | > Read more about [Scaffolder component](https://spiral.dev/docs/basics-scaffolding). 183 | 184 | ---- 185 | 186 | > **Note** 187 | > Read more about RoadRunner configuration on official site https://roadrunner.dev. 188 | 189 | ## License: 190 | 191 | MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained by [Spiral Scout](https://spiralscout.com). 192 | --------------------------------------------------------------------------------