From 99a9f731875205443c15d42220a371c4db06fe3f Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 19 Mar 2023 21:09:45 +0100 Subject: [PATCH 1/9] add sse support --- .idea/php.xml | 124 ++++++++++++++--------------- .idea/swytch-framework.iml | 124 ++++++++++++++--------------- src/Hooks/Api/SSE.php | 116 +++++++++++++++++++++++++++ src/Router/Attributes/SseRoute.php | 14 ++++ src/Router/SseMessage.php | 14 ++++ 5 files changed, 268 insertions(+), 124 deletions(-) create mode 100644 src/Hooks/Api/SSE.php create mode 100644 src/Router/Attributes/SseRoute.php create mode 100644 src/Router/SseMessage.php diff --git a/.idea/php.xml b/.idea/php.xml index 6e5004e..0c52b6c 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -12,95 +12,95 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/swytch-framework.iml b/.idea/swytch-framework.iml index f3bf585..7642d32 100644 --- a/.idea/swytch-framework.iml +++ b/.idea/swytch-framework.iml @@ -5,95 +5,95 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Hooks/Api/SSE.php b/src/Hooks/Api/SSE.php new file mode 100644 index 0000000..ac6ac99 --- /dev/null +++ b/src/Hooks/Api/SSE.php @@ -0,0 +1,116 @@ +|null $route + */ + $route = $request->getAttribute(Router::ATTRIBUTE_HANDLER); + + foreach (Attributes::findTargetMethods(SseAttribute::class) as $targetMethod) { + if ([$targetMethod->class, $targetMethod->name] === [$route->class, $route->name]) { + ignore_user_abort(true); + set_time_limit(0); + + $this->isFirstMessage = true; + + $this->messageGenerator = $targetMethod->attribute->messageGenerator; + $this->defaultRetryMs = $targetMethod->attribute->retryMs; + + // X-Accel-Buffering is only needed for Nginx, but it won't hurt on anything else. + $this->headers->setHeader('X-Accel-Buffering', 'no'); + $this->headers->setHeader('Content-Type', 'text/event-stream'); + $this->headers->setHeader('Cache-Control', 'no-cache'); + $this->headers->setHeader('Connection', 'keep-alive'); + } + } + + return $request; + } + + public function postprocess(ResponseInterface $response): ResponseInterface + { + if ($this->isFirstMessage === null) { + return $response; + } + $response->getBody()->rewind(); + if (($response->getBody()->getSize() ?? 0) === 0) { + register_shutdown_function($this->generateMessages(...)); + + return $response->withBody( + $this->psr17Factory->createStream( + ":" . str_repeat(" ", 2048) . "\nretry: " . $this->defaultRetryMs . "\n" + ) + ); // 2 kB padding for IE + } + + return $response; + } + + private function generateMessages(): void + { + $generator = $this->messageGenerator; + $reflection = new \ReflectionFunction($generator); + $returnType = $reflection->getReturnType(); + if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === SseMessage::class) { + while (true) { + if (connection_aborted()) { + break; + } + /** @var SseMessage $message */ + $message = $generator(); + $this->emitMessage($message); + } + } + if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === \Generator::class) { + foreach ($generator() as $message) { + if (connection_aborted()) { + break; + } + $this->emitMessage($message); + } + } + $this->logger->warning("SSE connection closed due to incorrect handler return type: " . $returnType?->getName() ?? ''); + } + + private function emitMessage(SseMessage $message): void + { + echo "event: " . $message->event . "\n"; + if ($message->id) { + echo "id: " . $message->id . "\n"; + } + if ($message->retryMs) { + echo "retry: " . $message->retryMs . "\n"; + } + echo "data: " . $message->data . "\n\n"; + } +} diff --git a/src/Router/Attributes/SseRoute.php b/src/Router/Attributes/SseRoute.php new file mode 100644 index 0000000..272cb80 --- /dev/null +++ b/src/Router/Attributes/SseRoute.php @@ -0,0 +1,14 @@ + Date: Sun, 19 Mar 2023 21:51:24 +0100 Subject: [PATCH 2/9] refactor into hooks --- src/Hooks/Api/Invoker.php | 16 +++- src/Hooks/Api/SSE.php | 116 ----------------------------- src/Hooks/Api/SSE/Preprocessor.php | 58 +++++++++++++++ src/Hooks/Api/SSE/Processor.php | 76 +++++++++++++++++++ src/Router/Attributes/SseRoute.php | 6 +- 5 files changed, 151 insertions(+), 121 deletions(-) delete mode 100644 src/Hooks/Api/SSE.php create mode 100644 src/Hooks/Api/SSE/Preprocessor.php create mode 100644 src/Hooks/Api/SSE/Processor.php diff --git a/src/Hooks/Api/Invoker.php b/src/Hooks/Api/Invoker.php index 9ff6835..0b23a7f 100644 --- a/src/Hooks/Api/Invoker.php +++ b/src/Hooks/Api/Invoker.php @@ -87,13 +87,23 @@ public function process(ServerRequestInterface $request, ResponseInterface $resp } throw new InvalidRequest('Unsupported parameter type in ' . $route->class . '::' . $route->name); } - $component = $this->factory->make($route->class); - $result = $componentMethod->invokeArgs($component, $arguments); + $result = $this->invoke($route->class, $route->name, $arguments); if (is_string($result)) { return $response->withBody($this->psr17Factory->createStream($result)); - } elseif ($result instanceof ResponseInterface) { + } + + if ($result instanceof ResponseInterface) { return $result; } + return $response; } + + protected function invoke( + string $class, + string $method, + array $arguments + ): mixed { + return $this->factory->make($class)->$method(...$arguments); + } } diff --git a/src/Hooks/Api/SSE.php b/src/Hooks/Api/SSE.php deleted file mode 100644 index ac6ac99..0000000 --- a/src/Hooks/Api/SSE.php +++ /dev/null @@ -1,116 +0,0 @@ -|null $route - */ - $route = $request->getAttribute(Router::ATTRIBUTE_HANDLER); - - foreach (Attributes::findTargetMethods(SseAttribute::class) as $targetMethod) { - if ([$targetMethod->class, $targetMethod->name] === [$route->class, $route->name]) { - ignore_user_abort(true); - set_time_limit(0); - - $this->isFirstMessage = true; - - $this->messageGenerator = $targetMethod->attribute->messageGenerator; - $this->defaultRetryMs = $targetMethod->attribute->retryMs; - - // X-Accel-Buffering is only needed for Nginx, but it won't hurt on anything else. - $this->headers->setHeader('X-Accel-Buffering', 'no'); - $this->headers->setHeader('Content-Type', 'text/event-stream'); - $this->headers->setHeader('Cache-Control', 'no-cache'); - $this->headers->setHeader('Connection', 'keep-alive'); - } - } - - return $request; - } - - public function postprocess(ResponseInterface $response): ResponseInterface - { - if ($this->isFirstMessage === null) { - return $response; - } - $response->getBody()->rewind(); - if (($response->getBody()->getSize() ?? 0) === 0) { - register_shutdown_function($this->generateMessages(...)); - - return $response->withBody( - $this->psr17Factory->createStream( - ":" . str_repeat(" ", 2048) . "\nretry: " . $this->defaultRetryMs . "\n" - ) - ); // 2 kB padding for IE - } - - return $response; - } - - private function generateMessages(): void - { - $generator = $this->messageGenerator; - $reflection = new \ReflectionFunction($generator); - $returnType = $reflection->getReturnType(); - if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === SseMessage::class) { - while (true) { - if (connection_aborted()) { - break; - } - /** @var SseMessage $message */ - $message = $generator(); - $this->emitMessage($message); - } - } - if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === \Generator::class) { - foreach ($generator() as $message) { - if (connection_aborted()) { - break; - } - $this->emitMessage($message); - } - } - $this->logger->warning("SSE connection closed due to incorrect handler return type: " . $returnType?->getName() ?? ''); - } - - private function emitMessage(SseMessage $message): void - { - echo "event: " . $message->event . "\n"; - if ($message->id) { - echo "id: " . $message->id . "\n"; - } - if ($message->retryMs) { - echo "retry: " . $message->retryMs . "\n"; - } - echo "data: " . $message->data . "\n\n"; - } -} diff --git a/src/Hooks/Api/SSE/Preprocessor.php b/src/Hooks/Api/SSE/Preprocessor.php new file mode 100644 index 0000000..61074fa --- /dev/null +++ b/src/Hooks/Api/SSE/Preprocessor.php @@ -0,0 +1,58 @@ +|null $route + */ + $route = $request->getAttribute(Router::ATTRIBUTE_HANDLER); + + foreach (Attributes::findTargetMethods(SseAttribute::class) as $targetMethod) { + if ([$targetMethod->class, $targetMethod->name] === [$route->class, $route->name]) { + ignore_user_abort(true); + set_time_limit(0); + + // X-Accel-Buffering is only needed for Nginx, but it won't hurt on anything else. + $this->headers->setHeader('X-Accel-Buffering', 'no'); + $this->headers->setHeader('Content-Type', 'text/event-stream'); + $this->headers->setHeader('Cache-Control', 'no-cache'); + $this->headers->setHeader('Connection', 'keep-alive'); + + $this->logger->debug('SSE Preprocessor: SSE route found, setting headers.'); + $request = $request->withAttribute('sse', true); + $this->lifecyleHooks->removeProcessor($this->invoker, 10); + } + } + + return $request; + } +} diff --git a/src/Hooks/Api/SSE/Processor.php b/src/Hooks/Api/SSE/Processor.php new file mode 100644 index 0000000..11f4c3c --- /dev/null +++ b/src/Hooks/Api/SSE/Processor.php @@ -0,0 +1,76 @@ +getAttribute('sse') ?? false) { + parent::process($request, $response); + } + return $response; + } + + protected function invoke( + string $class, + string $method, + array $arguments + ): mixed { + register_shutdown_function(function () use ($class, $method, $arguments) { + // output has already been emitted... so fall back to regular output systems. + // send a blank line with enough data so old browser don't choke + echo ":" . str_repeat(" ", 2048); + + $component = $this->factory->make($class); + callback: + $generator = $component->$method(...$arguments); + if ($generator instanceof \Generator) { + foreach ($generator as $data) { + if ($data instanceof SseMessage) { + $this->emitMessage($data); + continue; + } + } + } + if ($generator instanceof SseMessage) { + $this->emitMessage($generator); + goto callback; + } + }); + + return null; + } + + private function emitMessage(SseMessage $message): void + { + echo "event: " . $message->event . "\n"; + if ($message->id) { + echo "id: " . $message->id . "\n"; + } + if ($message->retryMs) { + echo "retry: " . $message->retryMs . "\n"; + } + echo "data: " . $message->data . "\n\n"; + } +} diff --git a/src/Router/Attributes/SseRoute.php b/src/Router/Attributes/SseRoute.php index 272cb80..3aad7f5 100644 --- a/src/Router/Attributes/SseRoute.php +++ b/src/Router/Attributes/SseRoute.php @@ -3,12 +3,14 @@ namespace Bottledcode\SwytchFramework\Router\Attributes; use Attribute; +use Bottledcode\SwytchFramework\Router\Method; use Closure; #[Attribute(Attribute::TARGET_METHOD)] -readonly class SseRoute +readonly class SseRoute extends Route { - public function __construct(public Closure $messageGenerator, public int $retryMs = 2000) + public function __construct(Method $method, string $path) { + parent::__construct($method, $path); } } From 60e9e27a8f573831c58682269126284a2a54fdf0 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 19 Mar 2023 22:13:53 +0100 Subject: [PATCH 3/9] try and resolve circular reference --- src/App.php | 2 +- src/LifecyleHooks.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index 3c84696..f123b8d 100644 --- a/src/App.php +++ b/src/App.php @@ -113,7 +113,7 @@ protected function createContainer(): ContainerInterface get('env.SWYTCH_LANGUAGE_DIR') ), Renderer::class => autowire(Renderer::class)->method('setRoot', get('app.root')), - LifecyleHooks::class => autowire(LifecyleHooks::class), + LifecyleHooks::class => autowire(LifecyleHooks::class)->method('load'), Headers::class => autowire(Headers::class), Psr17Factory::class => autowire(Psr17Factory::class), ServerRequestFactoryInterface::class => autowire(Psr17Factory::class), diff --git a/src/LifecyleHooks.php b/src/LifecyleHooks.php index 4d4dd0d..6eb951b 100644 --- a/src/LifecyleHooks.php +++ b/src/LifecyleHooks.php @@ -46,6 +46,10 @@ public function __construct( private array $middleware = [], private array $exceptionHandlers = [], ) { + } + + public function load(): static + { $classes = Attributes::findTargetClasses(Handler::class); foreach ($classes as $class) { try { @@ -71,6 +75,8 @@ public function __construct( continue; } } + + return $this; } /** From f19458732f7e917081783aa3510004f11c0702b6 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 19 Mar 2023 22:19:26 +0100 Subject: [PATCH 4/9] delay loading until just before use --- src/App.php | 2 +- src/Router/MagicRouter.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index f123b8d..3c84696 100644 --- a/src/App.php +++ b/src/App.php @@ -113,7 +113,7 @@ protected function createContainer(): ContainerInterface get('env.SWYTCH_LANGUAGE_DIR') ), Renderer::class => autowire(Renderer::class)->method('setRoot', get('app.root')), - LifecyleHooks::class => autowire(LifecyleHooks::class)->method('load'), + LifecyleHooks::class => autowire(LifecyleHooks::class), Headers::class => autowire(Headers::class), Psr17Factory::class => autowire(Psr17Factory::class), ServerRequestFactoryInterface::class => autowire(Psr17Factory::class), diff --git a/src/Router/MagicRouter.php b/src/Router/MagicRouter.php index cc96b1d..b227916 100644 --- a/src/Router/MagicRouter.php +++ b/src/Router/MagicRouter.php @@ -28,6 +28,7 @@ public function go(): ResponseInterface * @var LifecyleHooks $hooks */ $hooks = $this->container->get(LifecyleHooks::class); + $hooks->load(); /** * @var ServerRequestCreatorInterface $requestFactory */ From fd28812182feb45be4cfd31f10aff2ca8e9ba554 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 20 Mar 2023 22:57:34 +0100 Subject: [PATCH 5/9] get around output buffering --- src/Hooks/Api/SSE/Preprocessor.php | 3 +++ src/Hooks/Api/SSE/Processor.php | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Hooks/Api/SSE/Preprocessor.php b/src/Hooks/Api/SSE/Preprocessor.php index 61074fa..23b4864 100644 --- a/src/Hooks/Api/SSE/Preprocessor.php +++ b/src/Hooks/Api/SSE/Preprocessor.php @@ -40,6 +40,9 @@ public function preprocess(ServerRequestInterface $request, RequestType $type): if ([$targetMethod->class, $targetMethod->name] === [$route->class, $route->name]) { ignore_user_abort(true); set_time_limit(0); + ini_set('output_buffering', 'off'); + ini_set('zlib.output_compression', false); + ini_set('implicit_flush', true); // X-Accel-Buffering is only needed for Nginx, but it won't hurt on anything else. $this->headers->setHeader('X-Accel-Buffering', 'no'); diff --git a/src/Hooks/Api/SSE/Processor.php b/src/Hooks/Api/SSE/Processor.php index 11f4c3c..f4f960f 100644 --- a/src/Hooks/Api/SSE/Processor.php +++ b/src/Hooks/Api/SSE/Processor.php @@ -40,7 +40,7 @@ protected function invoke( register_shutdown_function(function () use ($class, $method, $arguments) { // output has already been emitted... so fall back to regular output systems. // send a blank line with enough data so old browser don't choke - echo ":" . str_repeat(" ", 2048); + echo ":" . str_repeat(" ", 8096); $component = $this->factory->make($class); callback: @@ -48,12 +48,13 @@ protected function invoke( if ($generator instanceof \Generator) { foreach ($generator as $data) { if ($data instanceof SseMessage) { + if(connection_aborted()) return; $this->emitMessage($data); - continue; } } } if ($generator instanceof SseMessage) { + if(connection_aborted()) return; $this->emitMessage($generator); goto callback; } @@ -72,5 +73,6 @@ private function emitMessage(SseMessage $message): void echo "retry: " . $message->retryMs . "\n"; } echo "data: " . $message->data . "\n\n"; + echo ":" . str_repeat(" ", 8096); } } From cf0b0229d396bf282292d34fdccce36f2bec1745 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 20 Mar 2023 23:15:59 +0100 Subject: [PATCH 6/9] fix comments --- src/Hooks/Api/SSE/Processor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hooks/Api/SSE/Processor.php b/src/Hooks/Api/SSE/Processor.php index f4f960f..525535c 100644 --- a/src/Hooks/Api/SSE/Processor.php +++ b/src/Hooks/Api/SSE/Processor.php @@ -73,6 +73,6 @@ private function emitMessage(SseMessage $message): void echo "retry: " . $message->retryMs . "\n"; } echo "data: " . $message->data . "\n\n"; - echo ":" . str_repeat(" ", 8096); + echo ":" . str_repeat(" ", 8096) . "\n\n"; } } From fa4c465de6346ce9c7e869c159df8748aaacc2ce Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 20 Mar 2023 23:50:48 +0100 Subject: [PATCH 7/9] make sure first event is emitted --- src/Hooks/Api/SSE/Processor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hooks/Api/SSE/Processor.php b/src/Hooks/Api/SSE/Processor.php index 525535c..2af598b 100644 --- a/src/Hooks/Api/SSE/Processor.php +++ b/src/Hooks/Api/SSE/Processor.php @@ -40,7 +40,7 @@ protected function invoke( register_shutdown_function(function () use ($class, $method, $arguments) { // output has already been emitted... so fall back to regular output systems. // send a blank line with enough data so old browser don't choke - echo ":" . str_repeat(" ", 8096); + echo ":" . str_repeat(" ", 8096) . "\n\n"; $component = $this->factory->make($class); callback: From 247feaba4bf526c113991394578a78c3a1b796cc Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 21 Mar 2023 00:47:05 +0100 Subject: [PATCH 8/9] register components on sse call --- src/Hooks/Api/SSE/Preprocessor.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Hooks/Api/SSE/Preprocessor.php b/src/Hooks/Api/SSE/Preprocessor.php index 23b4864..bbfaf29 100644 --- a/src/Hooks/Api/SSE/Preprocessor.php +++ b/src/Hooks/Api/SSE/Preprocessor.php @@ -7,11 +7,13 @@ use Bottledcode\SwytchFramework\Hooks\Api\Router; use Bottledcode\SwytchFramework\Hooks\Common\Headers; use Bottledcode\SwytchFramework\Hooks\Handler; +use Bottledcode\SwytchFramework\Hooks\Html\ComponentRegister; use Bottledcode\SwytchFramework\Hooks\PreprocessInterface; use Bottledcode\SwytchFramework\Hooks\RequestType; use Bottledcode\SwytchFramework\LifecyleHooks; use Bottledcode\SwytchFramework\Router\Attributes\Route; use Bottledcode\SwytchFramework\Router\Attributes\SseRoute as SseAttribute; +use Bottledcode\SwytchFramework\Template\Compiler; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\TargetMethod; use Psr\Http\Message\ServerRequestInterface; @@ -25,7 +27,8 @@ public function __construct( private readonly Headers $headers, private readonly LoggerInterface $logger, private readonly Invoker $invoker, - private readonly LifecyleHooks $lifecyleHooks + private readonly LifecyleHooks $lifecyleHooks, + private readonly Compiler $compiler ) { } @@ -56,6 +59,9 @@ public function preprocess(ServerRequestInterface $request, RequestType $type): } } + $registerComponents = new ComponentRegister($this->compiler); + $registerComponents->preprocess($request, $type); + return $request; } } From 79f53b145a003d7530f4a32ce17b20cbd1561e39 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 21 Mar 2023 00:53:20 +0100 Subject: [PATCH 9/9] handle multiline data --- src/Hooks/Api/SSE/Processor.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Hooks/Api/SSE/Processor.php b/src/Hooks/Api/SSE/Processor.php index 2af598b..4713125 100644 --- a/src/Hooks/Api/SSE/Processor.php +++ b/src/Hooks/Api/SSE/Processor.php @@ -48,13 +48,17 @@ protected function invoke( if ($generator instanceof \Generator) { foreach ($generator as $data) { if ($data instanceof SseMessage) { - if(connection_aborted()) return; + if (connection_aborted()) { + return; + } $this->emitMessage($data); } } } if ($generator instanceof SseMessage) { - if(connection_aborted()) return; + if (connection_aborted()) { + return; + } $this->emitMessage($generator); goto callback; } @@ -72,7 +76,7 @@ private function emitMessage(SseMessage $message): void if ($message->retryMs) { echo "retry: " . $message->retryMs . "\n"; } - echo "data: " . $message->data . "\n\n"; - echo ":" . str_repeat(" ", 8096) . "\n\n"; + array_map(static fn($x) => print("data: {$x}\n"), explode("\n", $message->data)); + echo "\n\n:" . str_repeat(" ", 8096) . "\n\n"; } }