Skip to content

Instantly share code, notes, and snippets.

@n3o77

n3o77/Kernel.php Secret

Created December 15, 2023 22:33
Show Gist options
  • Save n3o77/0c9bdd9f566eb23435a39953ea980f04 to your computer and use it in GitHub Desktop.
Save n3o77/0c9bdd9f566eb23435a39953ea980f04 to your computer and use it in GitHub Desktop.
Fetch updates for Symfony UX Turbo to support roles in twig templates

This is a quick prototype / workaround for the problem described here: symfony/ux#1331

Please use this more as guide and use your own judgement to make necessary changes for your purposes

  • Remove original SF UX Turbo TwigBroadcaster
  • Add own implementation and save all render parameters in a cache pool with some random key
  • Send random key to all connected clients
  • UpdateListenerController is a Stimulus Controller which will receive the keys
  • Clients receive the message with the key and throttles (25ms) a fetch request with all keys within the throttle time
  • Controller receives keys and renders the turbo-streams as usual in the user session (with very basic deduplication for rendering)
  • Client receives turbo updates which are passed to renderStreamMessage which triggers the usual turbo updates

UpdateListenerController also refreshes the cookie after 59min as the mercure authorization JWT usually is only valid for 1h. It's quite hacky as it just fetches the current URL to refresh the cookies and then reconnects the EventSource. This should be implemented in a nicer way for production...

<?php
//src/Kernel.php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
class Kernel extends BaseKernel implements CompilerPassInterface
{
use MicroKernelTrait;
public function process(ContainerBuilder $container): void
{
$container
->removeDefinition('turbo.broadcaster.action_renderer')
;
}
}
{% do mercure(['topic/'~entity.id], {'subscribe': ['topic/'~entity.id]}) %}
<div {{ turbo_update_listener('topic/'~entity.id, path('turbo.render-updates')) }}>
lorem...
</div>
<?php
// src/Controller/TurboController.php
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
#[Route(path: '/turbo', name: 'turbo.')]
class TurboController extends AbstractController
{
#[Route(path: '/render-updates', name: 'render-updates', methods: 'POST')]
public function renderUpdates(
Environment $twig,
Request $request,
CacheItemPoolInterface $cacheItemPool,
EntityManagerInterface $em
): Response {
$content = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$itemIds = $content['item_ids'];
$updates = [];
$renderedHashes = [];
foreach ($itemIds as $itemId) {
try {
$item = $cacheItemPool->getItem($itemId);
$itemContent = $item->get();
$hash = hash('sha256', $itemContent);
if (in_array($hash, $renderedHashes, true)) {
continue;
}
$toRender = json_decode($itemContent, true, 512, JSON_THROW_ON_ERROR);
$renderedHashes[] = $hash;
} catch (InvalidArgumentException|\JsonException $e) {
return new Response('');
}
$entity = null;
if ($toRender['action'] !== 'remove') {
$repo = $em->getRepository($toRender['entity']);
$entity = $repo->find($toRender['id']);
}
dump($toRender);
if ($toRender['action']) {
$updates[] = $twig
->load($toRender['template'])
->renderBlock($toRender['action'], [
'entity' => $entity,
'action' => $toRender['action'],
'id' => implode('-', (array)($toRender['id'] ?? [])),
]
);
} else {
$updates[] = $twig->render($toRender['template'], ['entity' => $entity]);
}
}
return new Response(implode("\n", $updates));
}
}
<?php
// src/Twig/TurboExtension.php
namespace App\Twig;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\UX\StimulusBundle\Helper\StimulusHelper;
use Symfony\UX\Turbo\Bridge\Mercure\Broadcaster;
use Symfony\UX\Turbo\Broadcaster\IdAccessor;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* This Twig extension to create abbreviation of number.
*/
class TurboExtension extends AbstractExtension
{
public function __construct(
private readonly HubInterface $hub,
#[Autowire(service: 'stimulus.helper')]
private readonly StimulusHelper $stimulusHelper,
#[Autowire(service: 'turbo.id_accessor')]
private readonly IdAccessor $idAccessor
) {}
/**
* {@inheritdoc}
*/
public function getFunctions(): array
{
return [
new TwigFunction('turbo_update_listener', [$this, 'turboUpdateListener'], ['needs_environment' => true, 'is_safe' => ['html']]),
];
}
public function turboUpdateListener(Environment $env, $topic, string $renderPath, string $transport = null, int $throttleInMs = 25): ?string
{
if (\is_object($topic)) {
$class = $topic::class;
if (!$id = $this->idAccessor->getEntityId($topic)) {
throw new \LogicException(sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class));
}
$topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
} elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
// Generate a URI template to subscribe to updates for all objects of this class
$topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
}
$stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
$stimulusAttributes->addController('update-listener', [
'topic' => $topic,
'renderUrl' => $renderPath,
'hub' => $this->hub->getPublicUrl(),
'throttleInMs' => $throttleInMs,
]);
return (string) $stimulusAttributes;
}
}
<?php
// src/Service/TurboService.php
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\String\ByteString;
readonly class TurboService
{
public function __construct(
private readonly CacheItemPoolInterface $cacheItemPool,
private readonly HubInterface $hub,
) {}
/**
* @throws \JsonException
*/
public function renderUpdate(
string $template,
?string $action = null,
null|string|int $id = null,
?string $class = null,
): string {
$itemKey = ByteString::fromRandom(12)->toString();
$item = $this->cacheItemPool->getItem($itemKey);
$item->expiresAfter(60);
$item->set(json_encode([
'template' => $template,
'action' => $action,
'id' => $id,
'entity' => $class,
], JSON_THROW_ON_ERROR));
$this->cacheItemPool->save($item);
return $itemKey;
}
/**
* @throws \JsonException
*/
public function renderAndBroadcast(
string|array $topics,
string $template,
?string $action = null,
null|string|int $id = null,
?string $class = null,
): void {
$this->hub->publish(new Update($topics, $this->renderUpdate($template, $action, $id, $class)));
}
}
<?php
// src/Broadcaster/TwigBroadcaster.php
namespace App\Broadcaster;
use App\Service\TurboService;
use Doctrine\Common\Util\ClassUtils;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\VarExporter\LazyObjectInterface;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
use Symfony\UX\Turbo\Broadcaster\IdAccessor;
#[AsDecorator(decorates: 'turbo.broadcaster.imux')]
class TwigBroadcaster implements BroadcasterInterface
{
private array $templatePrefixes = ['App\Entity\\' => 'broadcast/'];
public function __construct(
private readonly BroadcasterInterface $broadcaster,
private readonly TurboService $turboService,
private readonly IdAccessor $idAccessor = new IdAccessor()
) {
}
/**
* @throws \JsonException
*/
public function broadcast(object $entity, string $action, array $options): void
{
if (!isset($options['id']) && null !== $id = $this->idAccessor->getEntityId($entity)) {
$options['id'] = $id;
}
// handle proxies (both styles)
if ($entity instanceof LazyObjectInterface) {
$class = get_parent_class($entity);
if (false === $class) {
throw new \LogicException('Parent class missing');
}
} else {
$class = ClassUtils::getClass($entity);
}
if (null === $template = $options['template'] ?? null) {
$template = $class;
foreach ($this->templatePrefixes as $namespace => $prefix) {
if (str_starts_with($template, $namespace)) {
$template = substr_replace($template, $prefix, 0, \strlen($namespace));
break;
}
}
$template = str_replace('\\', '/', $template).'.stream.html.twig';
}
$options['rendered_action'] = $this->turboService->renderUpdate(
$template,
$action,
implode('-', (array)($options['id'] ?? [])),
$class
);
$this->broadcaster->broadcast($entity, $action, $options);
}
}
// stimulus-controller
import {Controller} from '@hotwired/stimulus';
import {renderStreamMessage} from "@hotwired/turbo";
import {throttle} from "lodash";
export default class UpdateListenerController extends Controller<HTMLFormElement> {
static values = {
topic: String,
hub: String,
renderUrl: String,
throttleInMs: {type: Number, default: 25}
};
cookieTime = 1000 * 60 * 59;
refreshRunning = false;
toRequestIds: string[] = [];
es: EventSource | undefined;
url: string | undefined;
declare readonly topicValue: string;
declare readonly hubValue: string;
declare readonly renderUrlValue: string;
declare readonly throttleInMsValue: number;
declare readonly hasHubValue: boolean;
declare readonly hasTopicValue: boolean;
declare readonly hasRenderUrlValue: boolean;
initialize() {
const errorMessages: string[] = [];
if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.');
if (!this.hasTopicValue) errorMessages.push('A "topic" value must be provided.');
if (!this.hasRenderUrlValue) errorMessages.push('A "renderUrl" value must be provided.');
if (errorMessages.length) throw new Error(errorMessages.join(' '));
const u = new URL(this.hubValue);
u.searchParams.append('topic', this.topicValue);
this.url = u.toString();
}
connect() {
if (!this.url) {
return;
}
this.connectEventSource();
window.setInterval(this.reconnect, this.cookieTime);
const throttledCb = throttle(this.requestUpdate.bind(this), this.throttleInMsValue, {leading: false});
this.es.addEventListener('message', (message) => {
console.log('message received', message.data);
this.toRequestIds.push(message.data);
throttledCb();
});
}
requestUpdate() {
const ids = this.toRequestIds;
this.toRequestIds = [];
fetch(this.renderUrlValue, {
method: 'POST',
body: JSON.stringify({item_ids: ids}),
}).then(res => res.text()).then(text => {
renderStreamMessage(text);
});
}
disconnect() {
this.disconnectEventSource();
}
connectEventSource() {
this.es = new EventSource(this.url);
}
disconnectEventSource() {
if (this.es) {
this.es.close();
}
}
reconnect() {
if (this.refreshRunning) {
return;
}
this.refreshRunning = true;
// Hacky solution to refresh cookies...
fetch(window.location.toString()).then(() => {
this.disconnectEventSource();
this.connectEventSource();
this.refreshRunning = false;
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment