Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
CRAP | |
0.00% |
0 / 1 |
ApiSseController | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
90 | |
0.00% |
0 / 1 |
sse | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
90 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Drupal\display_builder\Controller; |
6 | |
7 | use Drupal\display_builder\Event\DisplayBuilderEvents; |
8 | use Drupal\display_builder\InstanceInterface; |
9 | use Symfony\Component\HttpFoundation\EventStreamResponse; |
10 | use Symfony\Component\HttpFoundation\ServerEvent; |
11 | |
12 | /** |
13 | * Returns responses for Display builder routes. |
14 | */ |
15 | class 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 | } |