Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 38
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 / 38
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 / 38
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 string $builder_id
36   *   The builder ID.
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(string $builder_id): EventStreamResponse {
45    return new EventStreamResponse(function () use ($builder_id) {
46      $sessionId = $this->session->getId();
47      $collection = $this->sharedTempStoreFactory->get($this::SSE_COLLECTION);
48
49      // Infinite loop because we keep the HTTP transaction open until it is
50      // closed by the client.
51      // @phpstan-ignore-next-line
52      while (TRUE) {
53        // We start by sending an empty event to avoid HTTP timeout. We need
54        // at least the HTTP response headers to be sent back to client without
55        // waiting for a real event to occur.
56        yield new ServerEvent('');
57        $latest = $collection->get("{$builder_id}_latest");
58
59        if (!$latest) {
60          \sleep($this::REFRESH_WINDOW);
61
62          continue;
63        }
64
65        // The current session is the last one editing the builder. Do nothing.
66        // Use session ID to handle:
67        // - different users
68        // - the same user in different browsers
69        // Not handled: multiple tabs with the same user in the same browser.
70        if ($latest['sessionId'] === $sessionId) {
71          \sleep($this::REFRESH_WINDOW);
72
73          continue;
74        }
75
76        // If the last edit is older than STALE, do nothing
77        // as we consider that the builder has already been refreshed.
78        if ($latest['timestamp'] < $this->time->getCurrentTime() - $this::STALE) {
79          \sleep($this::REFRESH_WINDOW);
80
81          continue;
82        }
83
84        // Reload the instance to ensure we get the builder's latest
85        // state.
86        $builder = $this->entityTypeManager()->getStorage('display_builder_instance')
87          ->loadUnchanged($builder_id);
88
89        if (!$builder instanceof InstanceInterface) {
90          \sleep($this::REFRESH_WINDOW);
91
92          continue;
93        }
94        $this->builder = $builder;
95
96        // Recompute islands regarding the current user.
97        // Use ON_HISTORY_CHANGE because it is the event where most islands are
98        // updated.
99        $event = $this->createEventWithEnabledIsland(
100          DisplayBuilderEvents::ON_HISTORY_CHANGE,
101          $builder->getCurrentState(),
102          NULL,
103          NULL,
104        );
105
106        foreach ($event->getResult() as $island_id => $result) {
107          // Do nothing if the island is not updated.
108          if (empty($result)) {
109            continue;
110          }
111
112          // Do nothing if the island is empty.
113          if (!isset($result['content'])) {
114            continue;
115          }
116
117          $sse_target = "island-{$builder->id()}-{$island_id}";
118          $data = $this->renderer->renderInIsolation($result['content']);
119
120          // Need to send the data back as one line.
121          // @see https://www.reddit.com/r/htmx/comments/1mjhzl1/comment/n7bdf0s
122          $data = \str_replace(["\r\n", "\r", "\n"], ' ', (string) $data);
123
124          yield new ServerEvent($data, type: $sse_target);
125        }
126
127        \sleep($this::REFRESH_WINDOW);
128      }
129    });
130  }
131
132}