Repository navigation
Expand file tree
/
Copy pathEventStream.php
More file actions
151 lines (124 loc) · 4.01 KB
/
Copy pathEventStream.php
File metadata and controls
151 lines (124 loc) · 4.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
<?php
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
namespace Piwik\Plugins\ConsoleUI;
use Piwik\Plugins\ConsoleUI\Console\AnsiParser;
/**
* Server-Sent Events stream carrying the output of a command to the browser while it runs.
*
* Output is sent as style segments (see AnsiParser), never as HTML. Text a command echoes directly instead of
* writing to its output is captured by an output buffer and sent the same way, so it cannot break the stream.
*/
class EventStream
{
/** Beyond this, output is dropped so a verbose command cannot freeze the browser */
public const MAX_OUTPUT_BYTES = 2000000;
/** @var callable(string): void */
private $writer;
/** @var AnsiParser */
private $parser;
/** @var int */
private $outputBytes = 0;
/** @var bool */
private $truncated = false;
/** @var bool True while the stream writes its own events, which the output buffer lets through */
private $writing = false;
/** @var bool */
private $buffering = false;
/**
* @param callable(string): void|null $writer Receives the raw event text, echoes it by default
*/
public function __construct(?callable $writer = null)
{
$this->parser = new AnsiParser();
$this->writer = $writer ?: function (string $event): void {
$this->writing = true;
echo $event;
$this->writing = false;
flush();
};
}
public function open(): void
{
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Accel-Buffering: no'); // Nginx
header('X-Content-Type-Options: nosniff');
// Sends the headers right away: a command calling header() itself (plugin:list does) must not replace them
echo ": stream\n\n";
flush();
// A chunk size of 1 hands every echo to the handler right away
ob_start([$this, 'captureStrayOutput'], 1);
$this->buffering = true;
}
public function close(): void
{
$this->emit('done', ['truncated' => $this->truncated]);
if ($this->buffering) {
$this->buffering = false;
ob_end_flush();
}
}
public function output(string $text): void
{
$event = $this->formatOutput($text);
if ($event !== '') {
($this->writer)($event);
}
}
/**
* @param array<string, mixed> $data
*/
public function emit(string $type, array $data = []): void
{
($this->writer)(self::format($type, $data));
}
/**
* Output buffer handler: lets the events of the stream through, wraps anything else as command output.
*
* @internal
*/
public function captureStrayOutput(string $buffer): string
{
if ($this->writing || $buffer === '') {
return $buffer;
}
return $this->formatOutput($buffer);
}
public function isTruncated(): bool
{
return $this->truncated;
}
private function formatOutput(string $text): string
{
if ($this->truncated || $text === '') {
return '';
}
$this->outputBytes += strlen($text);
if ($this->outputBytes > self::MAX_OUTPUT_BYTES) {
$this->truncated = true;
return self::format('truncated');
}
$segments = $this->parser->parse($text);
if (empty($segments)) {
return '';
}
return self::format('output', ['segments' => $segments]);
}
/**
* @param array<string, mixed> $data
*/
private static function format(string $type, array $data = []): string
{
return 'data: ' . json_encode(['type' => $type] + $data, JSON_INVALID_UTF8_SUBSTITUTE) . "\n\n";
}
}