| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type * as Protocol from '../../generated/protocol.js'; |
| |
| /** |
| * Implements Server-Sent-Events protocl parsing as described by |
| * https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream |
| * |
| * Webpages can use SSE over fetch/XHR and not go through EventSource. DevTools |
| * only receives the raw binary data in this case, which means we have to decode |
| * and parse the event stream ourselves here. |
| * |
| * Implementation mostly ported over from blink |
| * third_party/blink/renderer/modules/eventsource/event_source_parser.cc. |
| */ |
| export class ServerSentEventsParser { |
| #onEventCallback: (eventType: string, data: string, eventId: string) => void; |
| #decoder: Base64TextDecoder; |
| |
| // Parser state. |
| #isRecognizingCrLf = false; |
| #line = ''; |
| #id = ''; |
| #data = ''; |
| #eventType = ''; |
| |
| constructor(callback: (eventType: string, data: string, eventId: string) => void, encodingLabel?: string) { |
| this.#onEventCallback = callback; |
| this.#decoder = new Base64TextDecoder(this.#onTextChunk.bind(this), encodingLabel); |
| } |
| |
| async addBase64Chunk(raw: Protocol.binary): Promise<void> { |
| await this.#decoder.addBase64Chunk(raw); |
| } |
| |
| addTextChunk(chunk: string): void { |
| this.#onTextChunk(chunk); |
| } |
| |
| #onTextChunk(chunk: string): void { |
| // A line consists of "this.#line" plus a slice of "chunk[start:<next new cr/lf>]". |
| let start = 0; |
| for (let i = 0; i < chunk.length; ++i) { |
| if (this.#isRecognizingCrLf && chunk[i] === '\n') { |
| // We found the latter part of "\r\n". |
| this.#isRecognizingCrLf = false; |
| ++start; |
| continue; |
| } |
| this.#isRecognizingCrLf = false; |
| if (chunk[i] === '\r' || chunk[i] === '\n') { |
| this.#line += chunk.substring(start, i); |
| this.#parseLine(); |
| this.#line = ''; |
| start = i + 1; |
| this.#isRecognizingCrLf = chunk[i] === '\r'; |
| } |
| } |
| this.#line += chunk.substring(start); |
| } |
| |
| #parseLine(): void { |
| if (this.#line.length === 0) { |
| // We dispatch an event when seeing an empty line. |
| if (this.#data.length > 0) { |
| const data = this.#data.slice(0, -1); // Remove the last newline. |
| this.#onEventCallback(this.#eventType || 'message', data, this.#id); |
| this.#data = ''; |
| } |
| this.#eventType = ''; |
| return; |
| } |
| |
| let fieldNameEnd = this.#line.indexOf(':'); |
| let fieldValueStart; |
| if (fieldNameEnd < 0) { |
| fieldNameEnd = this.#line.length; |
| fieldValueStart = fieldNameEnd; |
| } else { |
| fieldValueStart = fieldNameEnd + 1; |
| if (fieldValueStart < this.#line.length && this.#line[fieldValueStart] === ' ') { |
| // Skip a single space preceeding the value. |
| ++fieldValueStart; |
| } |
| } |
| const fieldName = this.#line.substring(0, fieldNameEnd); |
| if (fieldName === 'event') { |
| this.#eventType = this.#line.substring(fieldValueStart); |
| return; |
| } |
| if (fieldName === 'data') { |
| this.#data += this.#line.substring(fieldValueStart); |
| this.#data += '\n'; |
| } |
| if (fieldName === 'id') { |
| // We should do a check here whether the id field contains "\0" and ignore it. |
| this.#id = this.#line.substring(fieldValueStart); |
| } |
| // Ignore all other fields. Also ignore "retry", we won't forward that to the backend. |
| } |
| } |
| |
| /** |
| * Small helper class that can decode a stream of base64 encoded bytes. Specify the |
| * text encoding for the raw bytes via constructor. Default is utf-8. |
| */ |
| class Base64TextDecoder { |
| #decoder: TextDecoderStream; |
| #writer: WritableStreamDefaultWriter; |
| |
| constructor(onTextChunk: (chunk: string) => void, encodingLabel?: string) { |
| this.#decoder = new TextDecoderStream(encodingLabel); |
| this.#writer = this.#decoder.writable.getWriter(); |
| void this.#decoder.readable.pipeTo(new WritableStream({write: onTextChunk})); |
| } |
| |
| async addBase64Chunk(chunk: Protocol.binary): Promise<void> { |
| const binString = window.atob(chunk); |
| const bytes = Uint8Array.from(binString, m => m.codePointAt(0) as number); |
| |
| await this.#writer.ready; |
| await this.#writer.write(bytes); |
| } |
| } |