Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
eventsource-stream.js399 linesDownload Raw Back to eventsource
1'use strict'
2const { Transform } = require('node:stream')
3const { isASCIINumber, isValidLastEventId } = require('./util')
4
5/**
6 * @type {number[]} BOM
7 */
8const BOM = [0xEF, 0xBB, 0xBF]
9/**
10 * @type {10} LF
11 */
12const LF = 0x0A
13/**
14 * @type {13} CR
15 */
16const CR = 0x0D
17/**
18 * @type {58} COLON
19 */
20const COLON = 0x3A
21/**
22 * @type {32} SPACE
23 */
24const SPACE = 0x20
25
26/**
27 * @typedef {object} EventSourceStreamEvent
28 * @type {object}
29 * @property {string} [event] The event type.
30 * @property {string} [data] The data of the message.
31 * @property {string} [id] A unique ID for the event.
32 * @property {string} [retry] The reconnection time, in milliseconds.
33 */
34
35/**
36 * @typedef eventSourceSettings
37 * @type {object}
38 * @property {string} lastEventId The last event ID received from the server.
39 * @property {string} origin The origin of the event source.
40 * @property {number} reconnectionTime The reconnection time, in milliseconds.
41 */
42
43class EventSourceStream extends Transform {
44  /**
45   * @type {eventSourceSettings}
46   */
47  state = null
48
49  /**
50   * Leading byte-order-mark check.
51   * @type {boolean}
52   */
53  checkBOM = true
54
55  /**
56   * @type {boolean}
57   */
58  crlfCheck = false
59
60  /**
61   * @type {boolean}
62   */
63  eventEndCheck = false
64
65  /**
66   * @type {Buffer}
67   */
68  buffer = null
69
70  pos = 0
71
72  event = {
73    data: undefined,
74    event: undefined,
75    id: undefined,
76    retry: undefined
77  }
78
79  /**
80   * @param {object} options
81   * @param {eventSourceSettings} options.eventSourceSettings
82   * @param {Function} [options.push]
83   */
84  constructor (options = {}) {
85    // Enable object mode as EventSourceStream emits objects of shape
86    // EventSourceStreamEvent
87    options.readableObjectMode = true
88
89    super(options)
90
91    this.state = options.eventSourceSettings || {}
92    if (options.push) {
93      this.push = options.push
94    }
95  }
96
97  /**
98   * @param {Buffer} chunk
99   * @param {string} _encoding
100   * @param {Function} callback
101   * @returns {void}
102   */
103  _transform (chunk, _encoding, callback) {
104    if (chunk.length === 0) {
105      callback()
106      return
107    }
108
109    // Cache the chunk in the buffer, as the data might not be complete while
110    // processing it
111    // TODO: Investigate if there is a more performant way to handle
112    // incoming chunks
113    // see: https://github.com/nodejs/undici/issues/2630
114    if (this.buffer) {
115      this.buffer = Buffer.concat([this.buffer, chunk])
116    } else {
117      this.buffer = chunk
118    }
119
120    // Strip leading byte-order-mark if we opened the stream and started
121    // the processing of the incoming data
122    if (this.checkBOM) {
123      switch (this.buffer.length) {
124        case 1:
125          // Check if the first byte is the same as the first byte of the BOM
126          if (this.buffer[0] === BOM[0]) {
127            // If it is, we need to wait for more data
128            callback()
129            return
130          }
131          // Set the checkBOM flag to false as we don't need to check for the
132          // BOM anymore
133          this.checkBOM = false
134
135          // The buffer only contains one byte so we need to wait for more data
136          callback()
137          return
138        case 2:
139          // Check if the first two bytes are the same as the first two bytes
140          // of the BOM
141          if (
142            this.buffer[0] === BOM[0] &&
143            this.buffer[1] === BOM[1]
144          ) {
145            // If it is, we need to wait for more data, because the third byte
146            // is needed to determine if it is the BOM or not
147            callback()
148            return
149          }
150
151          // Set the checkBOM flag to false as we don't need to check for the
152          // BOM anymore
153          this.checkBOM = false
154          break
155        case 3:
156          // Check if the first three bytes are the same as the first three
157          // bytes of the BOM
158          if (
159            this.buffer[0] === BOM[0] &&
160            this.buffer[1] === BOM[1] &&
161            this.buffer[2] === BOM[2]
162          ) {
163            // If it is, we can drop the buffered data, as it is only the BOM
164            this.buffer = Buffer.alloc(0)
165            // Set the checkBOM flag to false as we don't need to check for the
166            // BOM anymore
167            this.checkBOM = false
168
169            // Await more data
170            callback()
171            return
172          }
173          // If it is not the BOM, we can start processing the data
174          this.checkBOM = false
175          break
176        default:
177          // The buffer is longer than 3 bytes, so we can drop the BOM if it is
178          // present
179          if (
180            this.buffer[0] === BOM[0] &&
181            this.buffer[1] === BOM[1] &&
182            this.buffer[2] === BOM[2]
183          ) {
184            // Remove the BOM from the buffer
185            this.buffer = this.buffer.subarray(3)
186          }
187
188          // Set the checkBOM flag to false as we don't need to check for the
189          this.checkBOM = false
190          break
191      }
192    }
193
194    while (this.pos < this.buffer.length) {
195      // If the previous line ended with an end-of-line, we need to check
196      // if the next character is also an end-of-line.
197      if (this.eventEndCheck) {
198        // If the the current character is an end-of-line, then the event
199        // is finished and we can process it
200
201        // If the previous line ended with a carriage return, we need to
202        // check if the current character is a line feed and remove it
203        // from the buffer.
204        if (this.crlfCheck) {
205          // If the current character is a line feed, we can remove it
206          // from the buffer and reset the crlfCheck flag
207          if (this.buffer[this.pos] === LF) {
208            this.buffer = this.buffer.subarray(this.pos + 1)
209            this.pos = 0
210            this.crlfCheck = false
211
212            // It is possible that the line feed is not the end of the
213            // event. We need to check if the next character is an
214            // end-of-line character to determine if the event is
215            // finished. We simply continue the loop to check the next
216            // character.
217
218            // As we removed the line feed from the buffer and set the
219            // crlfCheck flag to false, we basically don't make any
220            // distinction between a line feed and a carriage return.
221            continue
222          }
223          this.crlfCheck = false
224        }
225
226        if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
227          // If the current character is a carriage return, we need to
228          // set the crlfCheck flag to true, as we need to check if the
229          // next character is a line feed so we can remove it from the
230          // buffer
231          if (this.buffer[this.pos] === CR) {
232            this.crlfCheck = true
233          }
234
235          this.buffer = this.buffer.subarray(this.pos + 1)
236          this.pos = 0
237          if (
238            this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) {
239            this.processEvent(this.event)
240          }
241          this.clearEvent()
242          continue
243        }
244        // If the current character is not an end-of-line, then the event
245        // is not finished and we have to reset the eventEndCheck flag
246        this.eventEndCheck = false
247        continue
248      }
249
250      // If the current character is an end-of-line, we can process the
251      // line
252      if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
253        // If the current character is a carriage return, we need to
254        // set the crlfCheck flag to true, as we need to check if the
255        // next character is a line feed
256        if (this.buffer[this.pos] === CR) {
257          this.crlfCheck = true
258        }
259
260        // In any case, we can process the line as we reached an
261        // end-of-line character
262        this.parseLine(this.buffer.subarray(0, this.pos), this.event)
263
264        // Remove the processed line from the buffer
265        this.buffer = this.buffer.subarray(this.pos + 1)
266        // Reset the position as we removed the processed line from the buffer
267        this.pos = 0
268        // A line was processed and this could be the end of the event. We need
269        // to check if the next line is empty to determine if the event is
270        // finished.
271        this.eventEndCheck = true
272        continue
273      }
274
275      this.pos++
276    }
277
278    callback()
279  }
280
281  /**
282   * @param {Buffer} line
283   * @param {EventStreamEvent} event
284   */
285  parseLine (line, event) {
286    // If the line is empty (a blank line)
287    // Dispatch the event, as defined below.
288    // This will be handled in the _transform method
289    if (line.length === 0) {
290      return
291    }
292
293    // If the line starts with a U+003A COLON character (:)
294    // Ignore the line.
295    const colonPosition = line.indexOf(COLON)
296    if (colonPosition === 0) {
297      return
298    }
299
300    let field = ''
301    let value = ''
302
303    // If the line contains a U+003A COLON character (:)
304    if (colonPosition !== -1) {
305      // Collect the characters on the line before the first U+003A COLON
306      // character (:), and let field be that string.
307      // TODO: Investigate if there is a more performant way to extract the
308      // field
309      // see: https://github.com/nodejs/undici/issues/2630
310      field = line.subarray(0, colonPosition).toString('utf8')
311
312      // Collect the characters on the line after the first U+003A COLON
313      // character (:), and let value be that string.
314      // If value starts with a U+0020 SPACE character, remove it from value.
315      let valueStart = colonPosition + 1
316      if (line[valueStart] === SPACE) {
317        ++valueStart
318      }
319      // TODO: Investigate if there is a more performant way to extract the
320      // value
321      // see: https://github.com/nodejs/undici/issues/2630
322      value = line.subarray(valueStart).toString('utf8')
323
324      // Otherwise, the string is not empty but does not contain a U+003A COLON
325      // character (:)
326    } else {
327      // Process the field using the steps described below, using the whole
328      // line as the field name, and the empty string as the field value.
329      field = line.toString('utf8')
330      value = ''
331    }
332
333    // Modify the event with the field name and value. The value is also
334    // decoded as UTF-8
335    switch (field) {
336      case 'data':
337        if (event[field] === undefined) {
338          event[field] = value
339        } else {
340          event[field] += `\n${value}`
341        }
342        break
343      case 'retry':
344        if (isASCIINumber(value)) {
345          event[field] = value
346        }
347        break
348      case 'id':
349        if (isValidLastEventId(value)) {
350          event[field] = value
351        }
352        break
353      case 'event':
354        if (value.length > 0) {
355          event[field] = value
356        }
357        break
358    }
359  }
360
361  /**
362   * @param {EventSourceStreamEvent} event
363   */
364  processEvent (event) {
365    if (event.retry && isASCIINumber(event.retry)) {
366      this.state.reconnectionTime = parseInt(event.retry, 10)
367    }
368
369    if (event.id && isValidLastEventId(event.id)) {
370      this.state.lastEventId = event.id
371    }
372
373    // only dispatch event, when data is provided
374    if (event.data !== undefined) {
375      this.push({
376        type: event.event || 'message',
377        options: {
378          data: event.data,
379          lastEventId: this.state.lastEventId,
380          origin: this.state.origin
381        }
382      })
383    }
384  }
385
386  clearEvent () {
387    this.event = {
388      data: undefined,
389      event: undefined,
390      id: undefined,
391      retry: undefined
392    }
393  }
394}
395
396module.exports = {
397  EventSourceStream
398}
399 
codekingpro/portable-devtools · Team Ai