chrome-devtools-frontend
Version:
Chrome DevTools UI
123 lines (110 loc) • 4.2 kB
text/typescript
// 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);
}
#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);
}
}