1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\HookTrait;
8: use Atk4\Ui\Js\Jquery;
9: use Atk4\Ui\Js\JsBlock;
10: use Atk4\Ui\Js\JsExpressionable;
11:
12: class JsSse extends JsCallback
13: {
14: use HookTrait;
15:
16: /** Executed when user aborted, or disconnect browser, when using this SSE. */
17: public const HOOK_ABORTED = self::class . '@connectionAborted';
18:
19: /** @var bool Allows us to fall-back to standard functionality of JsCallback if browser does not support SSE. */
20: public $browserSupport = false;
21:
22: /** @var bool Show Loader when doing sse. */
23: public $showLoader = false;
24:
25: /** @var bool add window.beforeunload listener for closing js EventSource. Off by default. */
26: public $closeBeforeUnload = false;
27:
28: /** @var bool Keep execution alive or not if connection is close by user. False mean that execution will stop on user aborted. */
29: public $keepAlive = false;
30:
31: /** @var \Closure|null custom function for outputting (instead of echo) */
32: public $echoFunction;
33:
34: #[\Override]
35: protected function init(): void
36: {
37: parent::init();
38:
39: if ($this->getApp()->tryGetRequestQueryParam('__atk_sse')) {
40: $this->browserSupport = true;
41: $this->initSse();
42: }
43: }
44:
45: #[\Override]
46: public function jsExecute(): JsBlock
47: {
48: $this->assertIsInitialized();
49:
50: $options = ['url' => $this->getJsUrl()];
51: if ($this->showLoader) {
52: $options['showLoader'] = $this->showLoader;
53: }
54: if ($this->closeBeforeUnload) {
55: $options['closeBeforeUnload'] = $this->closeBeforeUnload;
56: }
57:
58: return new JsBlock([(new Jquery($this->getOwner() /* TODO element and loader element should be passed explicitly */))->atkServerEvent($options)]);
59: }
60:
61: #[\Override]
62: public function set($fx = null, $args = null)
63: {
64: if (!$fx instanceof \Closure) {
65: throw new \TypeError('$fx must be of type Closure');
66: }
67:
68: return parent::set(static function (Jquery $chain) use ($fx, $args) {
69: // TODO replace EventSource to support POST
70: // https://github.com/Yaffle/EventSource
71: // https://github.com/mpetazzoni/sse.js
72: // https://github.com/EventSource/eventsource
73: // https://github.com/byjg/jquery-sse
74: return $fx($chain, ...array_values($args ?? []));
75: });
76: }
77:
78: /**
79: * Sending an SSE action.
80: */
81: public function send(JsExpressionable $action, bool $success = true): void
82: {
83: if ($this->browserSupport) {
84: $ajaxec = $this->getAjaxec($action);
85: $this->sendEvent('js', $this->getApp()->encodeJson(['success' => $success, 'atkjs' => $ajaxec]), 'atkSseAction');
86: }
87: }
88:
89: /**
90: * @return never
91: */
92: #[\Override]
93: public function terminateAjax($ajaxec, $msg = null, bool $success = true): void
94: {
95: if ($this->browserSupport) {
96: if ($ajaxec) {
97: $this->sendEvent(
98: 'js',
99: $this->getApp()->encodeJson(['success' => $success, 'atkjs' => $ajaxec]),
100: 'atkSseAction'
101: );
102: }
103:
104: // no further output please
105: $this->getApp()->terminate();
106: }
107:
108: $this->getApp()->terminateJson(['success' => $success, 'atkjs' => $ajaxec]);
109: }
110:
111: /**
112: * Output a SSE Event.
113: */
114: public function sendEvent(string $id, string $data, string $eventName = null): void
115: {
116: $this->sendBlock($id, $data, $eventName);
117: }
118:
119: /**
120: * Send Data in buffer to client.
121: */
122: public function flush(): void
123: {
124: flush();
125: }
126:
127: private function output(string $content): void
128: {
129: if ($this->echoFunction) {
130: ($this->echoFunction)($content);
131:
132: return;
133: }
134:
135: // output headers and content
136: $app = $this->getApp();
137: \Closure::bind(static function () use ($app, $content): void {
138: $app->outputResponse($content);
139: }, null, $app)();
140: }
141:
142: public function sendBlock(string $id, string $data, string $eventName = null): void
143: {
144: if (connection_aborted()) {
145: $this->hook(self::HOOK_ABORTED);
146:
147: // stop execution when aborted if not keepAlive
148: if (!$this->keepAlive) {
149: $this->getApp()->callExit();
150: }
151: }
152:
153: $this->output('id: ' . $id . "\n");
154: if ($eventName !== null) {
155: $this->output('event: ' . $eventName . "\n");
156: }
157: $this->output($this->wrapData($data) . "\n");
158: $this->flush();
159: }
160:
161: /**
162: * Create SSE data string.
163: */
164: private function wrapData(string $string): string
165: {
166: return implode('', array_map(static function (string $v): string {
167: return 'data: ' . $v . "\n";
168: }, preg_split('~\r?\n|\r~', $string)));
169: }
170:
171: /**
172: * It will ignore user abort by default.
173: */
174: protected function initSse(): void
175: {
176: @set_time_limit(0); // disable time limit
177: ignore_user_abort(true);
178:
179: $this->getApp()->setResponseHeader('content-type', 'text/event-stream');
180:
181: // disable buffering for nginx, see https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffers
182: $this->getApp()->setResponseHeader('x-accel-buffering', 'no');
183:
184: // disable compression
185: @ini_set('zlib.output_compression', '0');
186: if (function_exists('apache_setenv')) {
187: @apache_setenv('no-gzip', '1');
188: }
189:
190: // prevent buffering
191: if (ob_get_level()) {
192: ob_end_flush();
193: }
194: }
195: }
196: