Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiSseController
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 1
 sse
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Controller;
6
7use Drupal\display_builder\Event\DisplayBuilderEvents;
8use Drupal\display_builder\InstanceInterface;
9use Symfony\Component\HttpFoundation\EventStreamResponse;
10use Symfony\Component\HttpFoundation\ServerEvent;
11
12/**
13 * Returns responses for Display builder routes.
14 */
15class ApiSseController extends ApiControllerBase {
16
17  /**
18   * Check if the builder needs to be refreshed after this period, in seconds.
19   */
20  public const int REFRESH_WINDOW = 2;
21
22  /**
23   * Refresh builder only if the last edit is older than this window.
24   */
25  public const int STALE = (self::REFRESH_WINDOW * 2) - 1;
26
27  /**
28   * Stream server side events for real-time collaboration.
29   *
30   * Update islands every time another user is triggering a state altering
31   * event: ON_UPDATE, ON_ATTACH_TO_ROOT, ON_ATTACH_TO_SLOT, ON_MOVE,
32   * ON_DELETE, ON_PRESET_SAVE, ON_SAVE and ON_HISTORY_CHANGE.
33   * Skip ON_ACTIVE.
34   *
35   * @param \Drupal\display_builder\InstanceInterface $display_builder_instance
36   *   Display builder instance.
37   *
38   * @return \Symfony\Component\HttpFoundation\EventStreamResponse
39   *   The event stream response.
40   *
41   * @see https://v1.htmx.org/extensions/server-sent-events/
42   * @see https://symfony.com/blog/new-in-symfony-7-3-simpler-server-event-streaming
43   */
44  public function sse(InstanceInterface $display_builder_instance): EventStreamResponse {
45    $builder_id = (string) $display_builder_instance->id();
46
47    return new EventStreamResponse(function () use ($builder_id) {
48      $sessionId = $this->session->getId();
49      $collection = $this->sharedTempStoreFactory->get($this::SSE_COLLECTION);
50
51      // Infinite loop because we keep the HTTP transaction open until it is
52      // closed by the client.
53      // @phpstan-ignore-next-line
54      while (TRUE) {
55        // We start by sending an empty event to avoid HTTP timeout. We need
56        // at least the HTTP response headers to be sent back to client without
57        // waiting for a real event to occur.
58        yield new ServerEvent('');
59        $latest = $collection->get("{$builder_id}_latest");
60
61        if (!$latest) {
62          \sleep($this::REFRESH_WINDOW);
63
64          continue;
65        }
66
67        // The current session is the last one editing the builder. Do nothing.
68        // Use session ID to handle:
69        // - different users
70        // - the same user in different browsers
71        // Not handled: multiple tabs with the same user in the same browser.
72        if ($latest['sessionId'] === $sessionId) {
73          \sleep($this::REFRESH_WINDOW);
74
75          continue;
76        }
77
78        // If the last edit is older than STALE, do nothing
79        // as we consider that the builder has already been refreshed.
80        if ($latest['timestamp'] < $this->time->getCurrentTime() - $this::STALE) {
81          \sleep($this::REFRESH_WINDOW);
82
83          continue;
84        }
85
86        // Reload the instance to ensure we get the builder's latest
87        // state.
88        $builder = $this->entityTypeManager()->getStorage('display_builder_instance')
89          ->loadUnchanged($builder_id);
90
91        if (!$builder instanceof InstanceInterface) {
92          \sleep($this::REFRESH_WINDOW);
93
94          continue;
95        }
96        $this->builder = $builder;
97
98        // Recompute islands regarding the current user.
99        // Use ON_HISTORY_CHANGE because it is the event where most islands are
100        // updated.
101        $event = $this->createEventWithEnabledIsland(
102          DisplayBuilderEvents::ON_HISTORY_CHANGE,
103          $builder->getCurrentState(),
104          NULL,
105          NULL,
106        );
107
108        foreach ($event->getResult() as $island_id => $result) {
109          // Do nothing if the island is not updated.
110          if (empty($result)) {
111            continue;
112          }
113
114          // Do nothing if the island is empty.
115          if (!isset($result['content'])) {
116            continue;
117          }
118
119          $sse_target = "island-{$builder->id()}-{$island_id}";
120          $data = $this->renderer->renderInIsolation($result['content']);
121
122          // Need to send the data back as one line.
123          // @see https://www.reddit.com/r/htmx/comments/1mjhzl1/comment/n7bdf0s
124          $data = \str_replace(["\r\n", "\r", "\n"], ' ', (string) $data);
125
126          yield new ServerEvent($data, type: $sse_target);
127        }
128
129        \sleep($this::REFRESH_WINDOW);
130      }
131    });
132  }
133
134}