Skip to content
File

Blob: src/workerd/api/eventsource.c++

19.5 KB
1// Copyright (c) 2017-2024 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5#include "eventsource.h"
6 
7#include "http.h"
8#include "messagechannel.h"
9#include "streams/common.h"
10 
11#include <workerd/io/features.h>
12#include <workerd/jsg/exception.h>
13#include <workerd/util/mimetype.h>
14 
15namespace workerd::api {
16 
17namespace {
18class EventSourceSink final: public WritableStreamSink {
19 public:
20 EventSourceSink(EventSource& eventSource): eventSource(eventSource) {}
21 
22 kj::Promise<void> write(kj::ArrayPtr<const kj::byte> buffer) override {
23 // The event stream is a new-line delimited format where each line represents an event.
24 // We need to scan the buffer for end-of-line characters. When we find one, everything before
25 // it is pushed into the event queue and we keep scanning. If we do not find an end-of-line
26 // sequence in the remaining input, we buffer it and wait for the next write to continue
27 // scanning, or until the stream is ended or aborted.
28 
29 if (eventSource == kj::none) {
30 // Write was received after end() or abort() was called.
31 // We'll just ignore the write.
32 return kj::READY_NOW;
33 }
34 
35 auto input = buffer.asChars();
36 
37 // The stream may or may not begin with the UTF-8 BOM (%xFEFF). If this is the
38 // first write, we need to check for it and skip it if it is present.
39 // The BOM is a 3-byte sequence (0xEF, 0xBB, 0xBF) that encodes the Unicode
40 // codepoint U+FEFF. We only want to check for this once.
41 if (!bomChecked) {
42 bomChecked = true;
43 if (input.size() >= 3 && input[0] == '\xEF' && input[1] == '\xBB' && input[2] == '\xBF') {
44 input = input.slice(3);
45 }
46 }
47 
48 while (input != nullptr) {
49 KJ_IF_SOME(found, findEndOfLine(input)) {
50 auto prefix = kept.releaseAsArray();
51 // Feed the line into the processor.
52 feed(kj::str(prefix, input.first(found.pos)));
53 input = found.remaining;
54 // If we've reached the end of the input, input will == nullptr here.
55 } else {
56 // No end-of-line found, buffer the input.
57 kept.addAll(input.begin(), input.end());
58 input = nullptr;
59 }
60 }
61 
62 // Release any buffered events to the EventSource
63 release();
64 
65 return kj::READY_NOW;
66 }
67 
68 kj::Promise<void> write(kj::ArrayPtr<const kj::ArrayPtr<const kj::byte>> pieces) override {
69 for (auto& piece: pieces) {
70 co_await write(piece);
71 }
72 co_return;
73 }
74 
75 kj::Promise<void> end() override {
76 // The stream has finished. There's really nothing left to do here. Any partially
77 // filled data will be dropped on the floor.
78 clear();
79 return kj::READY_NOW;
80 }
81 
82 void abort(kj::Exception reason) override {
83 // There's really nothing to do here.
84 clear();
85 }
86 
87 private:
88 kj::Maybe<EventSource&> eventSource;
89 
90 // Retained bytes to be processed in the next write.
91 kj::Vector<char> kept;
92 
93 // The collected messages that are pending to be dispatched as events
94 kj::Vector<EventSource::PendingMessage> pendingMessages;
95 
96 // The message that is currently being processed.
97 kj::Maybe<EventSource::PendingMessage> currentPendingMessage;
98 
99 // Set to true once the byte-order-mark has been checked
100 bool bomChecked = false;
101 
102 EventSource::PendingMessage& getPendingMessage() {
103 KJ_IF_SOME(pending, currentPendingMessage) {
104 return pending;
105 }
106 return currentPendingMessage.emplace();
107 }
108 
109 void feed(kj::String line) {
110 // Parse line according to the event stream format and dispatch the event.
111 
112 // stream = [ bom ] *event
113 // event = *( comment / field ) end-of-line
114 // comment = colon *any-char end-of-line
115 // field = 1*name-char [ colon [ space ] *any-char ] end-of-line
116 // end-of-line = ( cr lf / cr / lf )
117 
118 // ; characters
119 // lf = %x000A ; U+000A LINE FEED (LF)
120 // cr = %x000D ; U+000D CARRIAGE RETURN (CR)
121 // space = %x0020 ; U+0020 SPACE
122 // colon = %x003A ; U+003A COLON (:)
123 // bom = %xFEFF ; U+FEFF BYTE ORDER MARK
124 // name-char = %x0000-0009 / %x000B-000C / %x000E-0039 / %x003B-10FFFF
125 // ; a scalar value other than U+000A LINE FEED (LF), U+000D CARRIAGE RETURN
126 // (CR), or U+003A COLON (:)
127 // any-char = %x0000-0009 / %x000B-000C / %x000E-10FFFF
128 // ; a scalar value other than U+000A LINE FEED (LF) or U+000D CARRIAGE
129 // RETURN (CR)
130 
131 // Note that the BOM (if present) is filtered out in the write() method.
132 
133 if (line.size() == 0) {
134 // Dispatch the current pending message and clear it. If there is no
135 // pending message, we'll just ignore the line.
136 KJ_IF_SOME(pending, currentPendingMessage) {
137 // This message is done and ready to be dispatched. Add it to the
138 // pendingMessages list. The next time release() is called, it will
139 // be passed off to the EventSource.
140 pending.id = kj::str(KJ_ASSERT_NONNULL(eventSource).getLastEventId());
141 pendingMessages.add(kj::mv(pending));
142 currentPendingMessage = kj::none;
143 }
144 } else if (line[0] == ':') {
145 // Ignore the line.
146 } else {
147 static constexpr auto handle = [](auto& self, kj::ArrayPtr<const char> field,
148 kj::ArrayPtr<const char> value) {
149 auto& pending = self.getPendingMessage();
150 auto& ev = KJ_ASSERT_NONNULL(self.eventSource);
151 // Per the spec, only one space after the colon is optional and trimmed.
152 // Any other whitespace, or additional spaces aren't accounted for so would
153 // be part of the value.
154 if (value.size() > 0 && value[0] == ' ') {
155 value = value.slice(1);
156 }
157 if (field == "data"_kjc) {
158 pending.data.add(kj::str(value));
159 } else if (field == "event"_kjc) {
160 pending.event = kj::str(value);
161 } else if (field == "id"_kjc) {
162 ev.setLastEventId(kj::str(value));
163 } else if (field == "retry"_kjc) {
164 KJ_IF_SOME(time, kj::str(value).tryParseAs<uint32_t>()) {
165 KJ_ASSERT_NONNULL(self.eventSource).setReconnectionTime(time);
166 }
167 // Ignore the line if it cannot be successfully parsed as a uint32_t
168 }
169 };
170 
171 KJ_IF_SOME(pos, line.findFirst(':')) {
172 handle(*this, line.first(pos), line.slice(pos + 1));
173 } else {
174 handle(*this, line, ""_kjc);
175 }
176 }
177 }
178 
179 void release() {
180 if (pendingMessages.empty()) return;
181 auto pending = pendingMessages.releaseAsArray();
182 // If the event source is gone, just drop the messages on the floor.
183 KJ_IF_SOME(es, eventSource) {
184 es.enqueueMessages(kj::mv(pending));
185 }
186 }
187 
188 void clear() {
189 eventSource = kj::none;
190 kept.clear();
191 pendingMessages.clear();
192 currentPendingMessage = kj::none;
193 }
194 
195 struct EndOfLine {
196 size_t pos;
197 kj::ArrayPtr<const char> remaining;
198 };
199 kj::Maybe<EndOfLine> findEndOfLine(kj::ArrayPtr<const char> input) {
200 // The end-of-line marker is either \n, \r, or \r\n
201 size_t pos = 0;
202 while (pos < input.size()) {
203 if (input[pos] == '\n') {
204 return EndOfLine{pos, input.slice(pos + 1)};
205 } else if (input[pos] == '\r') {
206 if (pos + 1 < input.size() && input[pos + 1] == '\n') {
207 return EndOfLine{pos, input.slice(pos + 2)};
208 }
209 return EndOfLine{pos, input.slice(pos + 1)};
210 }
211 pos++;
212 }
213 return kj::none;
214 }
215};
216 
217kj::Promise<void> processBody(IoContext& context, kj::Promise<DeferredProxy<void>> promise) {
218 try {
219 co_await context.waitForDeferredProxy(kj::mv(promise));
220 } catch (...) {
221 auto ex = kj::getCaughtExceptionAsKj();
222 // We would see a disconnection exception if the eventstream is closed for
223 // multiple kinds of reasons. If it's a network error and we know we can
224 // reconnect, we should try to reconnect.
225 if (ex.getType() == kj::Exception::Type::DISCONNECTED) {
226 co_return;
227 }
228 // Propagate the exception up.
229 kj::throwFatalException(kj::mv(ex));
230 }
231}
232} // namespace
233 
234jsg::Ref<EventSource> EventSource::constructor(
235 jsg::Lock& js, kj::String url, jsg::Optional<EventSourceInit> init) {
236 JSG_REQUIRE(IoContext::hasCurrent(), DOMNotSupportedError,
237 "An EventSource can only be created within the context of a worker request.");
238 
239 KJ_IF_SOME(i, init) {
240 KJ_IF_SOME(withCredentials, i.withCredentials) {
241 JSG_REQUIRE(!withCredentials, DOMNotSupportedError,
242 "The init.withCredentials option is not supported. It must be false or undefined.");
243 }
244 }
245 
246 auto eventsource = js.alloc<EventSource>(js,
247 JSG_REQUIRE_NONNULL(jsg::Url::tryParse(url.asPtr()), DOMSyntaxError,
248 kj::str("Cannot open an EventSource to '", url, "'. The URL is invalid.")),
249 kj::mv(init));
250 eventsource->start(js);
251 return kj::mv(eventsource);
252}
253 
254jsg::Ref<EventSource> EventSource::from(jsg::Lock& js, jsg::Ref<ReadableStream> readable) {
255 JSG_REQUIRE(IoContext::hasCurrent(), DOMNotSupportedError,
256 "An EventSource can only be created within the context of a worker request.");
257 JSG_REQUIRE(!readable->isLocked(), TypeError, "This ReadableStream is locked.");
258 JSG_REQUIRE(
259 !readable->isDisturbed(), TypeError, "This ReadableStream has already been read from.");
260 auto eventsource = js.alloc<EventSource>(js);
261 eventsource->run(js, kj::mv(readable), false /* No reconnection attempts */);
262 return kj::mv(eventsource);
263}
264 
265EventSource::EventSource(jsg::Lock& js, jsg::Url url, kj::Maybe<EventSourceInit> init)
266 : context(IoContext::current()),
267 impl({
268 .url = kj::mv(url),
269 .options = kj::mv(init).orDefault({}),
270 }),
271 abortController(js.alloc<AbortController>(js)),
272 readyState(State::CONNECTING) {}
273 
274EventSource::EventSource(jsg::Lock& js)
275 : context(IoContext::current()),
276 abortController(js.alloc<AbortController>(js)),
277 readyState(State::CONNECTING) {}
278 
279void EventSource::notifyError(jsg::Lock& js, const jsg::JsValue& error, bool reconnecting) {
280 if (readyState == State::CLOSED) return;
281 
282 // Abort the connection if it hasn't already been. This will be a non-op if the
283 // controller has already been aborted.
284 abortController->abort(js, error);
285 
286 if (!reconnecting)
287 readyState = State::CLOSED;
288 else
289 readyState = State::CONNECTING;
290 
291 // Dispatch the error event.
292 dispatchEventImpl(js, js.alloc<ErrorEvent>(js, error));
293 
294 // Log the error as an uncaught exception for debugging purposes.
295 IoContext::current().logUncaughtException(UncaughtExceptionSource::ASYNC_TASK, error);
296}
297 
298void EventSource::notifyOpen(jsg::Lock& js) {
299 if (readyState == State::CLOSED) return;
300 readyState = State::OPEN;
301 dispatchEventImpl(js, js.alloc<OpenEvent>());
302}
303 
304void EventSource::notifyMessages(jsg::Lock& js, kj::Array<PendingMessage> messages) {
305 if (readyState == State::CLOSED) return;
306 js.tryCatch([&] {
307 for (auto& message: messages) {
308 auto data = kj::str(kj::delimited(kj::mv(message.data), "\n"_kjc));
309 if (data.size() == 0) continue;
310 kj::String type = kj::mv(message.event).orDefault([]() { return kj::str("message"); });
311 dispatchEventImpl(js,
312 js.alloc<MessageEvent>(js, kj::mv(type), js.str(data), kj::mv(message.id),
313 kj::none /** source **/, impl.map([](FetchImpl& i) -> jsg::Url& { return i.url; })));
314 }
315 }, [&](jsg::Value exception) {
316 // If we end up with an exception being thrown in one of the event handlers, we will
317 // stop trying to process the messages and instead just error the EventSource.
318 notifyError(js, jsg::JsValue(exception.getHandle(js)));
319 });
320}
321 
322void EventSource::reconnect(jsg::Lock& js) {
323 KJ_ASSERT(impl != kj::none);
324 readyState = State::CONNECTING;
325 abortController = js.alloc<AbortController>(js);
326 auto signal = abortController->getSignal();
327 context.awaitIo(js, signal->wrap(js, context.afterLimitTimeout(reconnectionTime)))
328 .then(js,
329 JSG_VISITABLE_LAMBDA(
330 (self = JSG_THIS), (self), (jsg::Lock & js) mutable { self->start(js); }),
331 JSG_VISITABLE_LAMBDA((self = JSG_THIS), (self), (jsg::Lock& js, jsg::Value exception) {
332 // In this case, it is most likely the EventSource was closed by the user or
333 // there was some other failure. We should not continue trying to reconnect.
334 self->notifyError(js, jsg::JsValue(exception.getHandle(js)));
335 }));
336}
337 
338void EventSource::start(jsg::Lock& js) {
339 auto& i = KJ_ASSERT_NONNULL(impl);
340 if (readyState == State::CLOSED) return;
341 
342 auto fetcher = i.options.fetcher.map([](jsg::Ref<Fetcher>& f) { return f.addRef(); });
343 
344 static constexpr auto handleError = [](auto& js, auto& self, kj::String message) {
345 auto ex = js.domException(kj::str("AbortError"), kj::mv(message));
346 auto handle = KJ_ASSERT_NONNULL(ex.tryGetHandle(js));
347 self->notifyError(js, jsg::JsValue(handle));
348 return js.resolvedPromise();
349 };
350 
351 auto onSuccess = JSG_VISITABLE_LAMBDA(
352 (self = JSG_THIS, fetcher = fetcher.map([](jsg::Ref<Fetcher>& f) -> jsg::Ref<Fetcher> {
353 return f.addRef();
354 })),
355 (self, fetcher), (jsg::Lock& js, jsg::Ref<Response> response) {
356 if (self->readyState == State::CLOSED) return js.resolvedPromise();
357 auto& impl = KJ_ASSERT_NONNULL(self->impl);
358 if (!response->getOk()) {
359 // Response status code is not 2xx, so we fail.
360 // No reconnection attempt should be made.
361 return handleError(
362 js, self, kj::str("The response status code was ", response->getStatus(), "."));
363 }
364 
365 KJ_IF_SOME(contentType,
366 response->getHeaders(js)->getCommon(js, capnp::CommonHeaderName::CONTENT_TYPE)) {
367 bool invalid = false;
368 KJ_IF_SOME(parsed, MimeType::tryParse(contentType)) {
369 invalid = parsed != MimeType::EVENT_STREAM;
370 } else {
371 invalid = true;
372 }
373 if (invalid) {
374 // No reconnection attempt should be made.
375 return handleError(js, self, kj::str("The content type '", contentType, "' is invalid."));
376 }
377 } else {
378 // No reconnection attempt should be made.
379 return handleError(
380 js, self, kj::str("No content type header was present in the response."));
381 }
382 
383 // If the request was redirected, update the URL to the new location.
384 if (response->getRedirected()) {
385 KJ_IF_SOME(newUrl, jsg::Url::tryParse(response->getUrl())) {
386 impl.url = kj::mv(newUrl);
387 } else {
388 } // Extra else block to squash compiler warning
389 }
390 
391 KJ_IF_SOME(body, response->getBody()) {
392 // Well, ok! We're ready to start trying to process the stream! We do so by
393 // pumping the body into an EventSourceSink until the body is closed, canceled,
394 // or errored.
395 self->run(js, kj::mv(body), true, response.addRef(), kj::mv(fetcher));
396 return js.resolvedPromise();
397 } else {
398 auto& i = KJ_ASSERT_NONNULL(self->impl);
399 // If there is no body, there's nothing to do. We'll treat this as if
400 // the server disconnected. If it only happens once, we'll try to reconnect.
401 // If it happens again, we'll fail the connection as it is likely indicative
402 // of a bug in the server or along the path to the server.
403 if (i.previousNoBody) {
404 self->notifyError(js, js.error("The server provided no content."));
405 } else {
406 i.previousNoBody = true;
407 self->notifyError(js, js.error("The server provided no content. Will try reconnecting."),
408 true /* reconnecting */);
409 self->reconnect(js);
410 }
411 return js.resolvedPromise();
412 }
413 });
414 
415 auto onFailed =
416 JSG_VISITABLE_LAMBDA((self = JSG_THIS), (self), (jsg::Lock& js, jsg::Value exception) {
417 self->notifyError(js, jsg::JsValue(exception.getHandle(js)));
418 return js.resolvedPromise();
419 });
420 
421 auto headers = js.alloc<Headers>();
422 headers->setCommon(capnp::CommonHeaderName::ACCEPT, MimeType::EVENT_STREAM.essence());
423 headers->setCommon(capnp::CommonHeaderName::CACHE_CONTROL, kj::str("no-cache"));
424 if (lastEventId != ""_kjc) {
425 headers->setUnguarded(js, kj::str("last-event-id"), kj::str(lastEventId));
426 }
427 
428 fetchImpl(js, kj::mv(fetcher), kj::str(i.url),
429 RequestInitializerDict{
430 .headers = kj::mv(headers),
431 .signal = abortController->getSignal(),
432 })
433 .then(js, kj::mv(onSuccess), kj::mv(onFailed));
434}
435 
436namespace {
437template <typename T>
438kj::Maybe<jsg::Ref<T>> addRef(kj::Maybe<jsg::Ref<T>>& ref) {
439 return ref.map([](jsg::Ref<T>& r) { return r.addRef(); });
440}
441} // namespace
442 
443void EventSource::run(jsg::Lock& js,
444 jsg::Ref<ReadableStream> readable,
445 bool withReconnection,
446 kj::Maybe<jsg::Ref<Response>> response,
447 kj::Maybe<jsg::Ref<Fetcher>> fetcher) {
448 notifyOpen(js);
449 
450 KJ_IF_SOME(resp, response) {
451 JSG_REQUIRE(resp->getType() != "error"_kj, TypeError,
452 "Error responses are unsupported with EventSource");
453 }
454 
455 auto onSuccess =
456 JSG_VISITABLE_LAMBDA((self = JSG_THIS, readable = readable.addRef(), withReconnection,
457 response = addRef(response), fetcher = addRef(fetcher)),
458 (self, readable, response, fetcher), (jsg::Lock& js) {
459 // The pump finished. Did the server disconnect? If so, try reconnecting if we can.
460 self->notifyError(js, js.error("The server disconnected."), withReconnection);
461 if (withReconnection) self->reconnect(js);
462 });
463 
464 auto onFailed = JSG_VISITABLE_LAMBDA(
465 (self = JSG_THIS, response = addRef(response), fetcher = addRef(fetcher)),
466 (self, response, fetcher), (jsg::Lock& js, jsg::Value exception) {
467 // If the pump fails, catch the error and convert it into an error event.
468 // If we got here, it likely isn't just a DISCONNECT event. Let's not
469 // try to reconnect at this point.
470 self->notifyError(js, jsg::JsValue(exception.getHandle(js)));
471 });
472 
473 // Well, ok! We're ready to start trying to process the stream! We do so by
474 // pumping the body into an EventSourceSink until the body is closed, canceled,
475 // or errored.
476 context
477 .awaitIo(
478 js, processBody(context, readable->pumpTo(js, kj::heap<EventSourceSink>(*this), true)))
479 .then(js, kj::mv(onSuccess), kj::mv(onFailed));
480}
481 
482void EventSource::close(jsg::Lock& js) {
483 if (closeCalled) return;
484 closeCalled = true;
485 abortController->abort(js, kj::none);
486 readyState = State::CLOSED;
487}
488 
489void EventSource::enqueueMessages(kj::Array<PendingMessage> messages) {
490 context.addTask(context.run([this, messages = kj::mv(messages)](
491 auto& lock) mutable { notifyMessages(lock, kj::mv(messages)); }));
492}
493 
494void EventSource::setReconnectionTime(uint32_t time) {
495 // We enforce both a min and max reconnection time. The minimum is 1 second,
496 // and the maximum is 10 seconds.
497 reconnectionTime =
498 kj::max(kj::min(time, MAX_RECONNECTION_TIME), MIN_RECONNECTION_TIME) * kj::MILLISECONDS;
499}
500 
501kj::StringPtr EventSource::getLastEventId() {
502 return lastEventId;
503}
504 
505void EventSource::setLastEventId(kj::String id) {
506 lastEventId = kj::mv(id);
507}
508 
509void EventSource::visitForGc(jsg::GcVisitor& visitor) {
510 KJ_IF_SOME(i, impl) {
511 visitor.visit(i.options.fetcher);
512 }
513 visitor.visit(abortController);
514}
515 
516void EventSource::visitForMemoryInfo(jsg::MemoryTracker& tracker) const {
517 KJ_IF_SOME(i, impl) {
518 tracker.trackField("fetcher", i.options.fetcher);
519 tracker.trackField("url", i.url);
520 }
521 tracker.trackField("abortController", abortController);
522 tracker.trackField("lastEventId", lastEventId);
523}
524 
525} // namespace workerd::api