mirror of
https://github.com/ikechan8370/chatgpt-plugin.git
synced 2025-12-17 05:47:11 +00:00
fix: remove useless thing
This commit is contained in:
parent
5c544a5ca7
commit
bd7aac0517
53 changed files with 350 additions and 2639 deletions
932
utils/browser.js
932
utils/browser.js
|
|
@ -1,10 +1,5 @@
|
|||
import lodash from 'lodash'
|
||||
import { Config } from '../utils/config.js'
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
|
||||
import { getOpenAIAuth } from './openai-auth.js'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import common from '../../../lib/common/common.js'
|
||||
const chatUrl = 'https://chat.openai.com/chat'
|
||||
import { Config } from './config.js'
|
||||
let puppeteer = {}
|
||||
|
||||
class Puppeteer {
|
||||
|
|
@ -48,19 +43,9 @@ class Puppeteer {
|
|||
|
||||
async initPupp () {
|
||||
if (!lodash.isEmpty(puppeteer)) return puppeteer
|
||||
puppeteer = (await import('puppeteer-extra')).default
|
||||
const pluginStealth = StealthPlugin()
|
||||
puppeteer.use(pluginStealth)
|
||||
if (Config['2captchaToken']) {
|
||||
const pluginCaptcha = (await import('puppeteer-extra-plugin-recaptcha')).default
|
||||
puppeteer.use(pluginCaptcha({
|
||||
provider: {
|
||||
id: '2captcha',
|
||||
token: Config['2captchaToken'] // REPLACE THIS WITH YOUR OWN 2CAPTCHA API KEY ⚡
|
||||
},
|
||||
visualFeedback: true
|
||||
}))
|
||||
}
|
||||
puppeteer = (await import('puppeteer')).default
|
||||
// const pluginStealth = StealthPlugin()
|
||||
// puppeteer.use(pluginStealth)
|
||||
return puppeteer
|
||||
}
|
||||
|
||||
|
|
@ -109,25 +94,10 @@ export class ChatGPTPuppeteer extends Puppeteer {
|
|||
constructor (opts = {}) {
|
||||
super()
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
markdown = true,
|
||||
debug = false,
|
||||
isGoogleLogin = false,
|
||||
minimize = true,
|
||||
captchaToken,
|
||||
executablePath
|
||||
debug = false
|
||||
} = opts
|
||||
|
||||
this._email = email
|
||||
this._password = password
|
||||
|
||||
this._markdown = !!markdown
|
||||
this._debug = !!debug
|
||||
this._isGoogleLogin = !!isGoogleLogin
|
||||
this._minimize = !!minimize
|
||||
this._captchaToken = captchaToken
|
||||
this._executablePath = executablePath
|
||||
}
|
||||
|
||||
async getBrowser () {
|
||||
|
|
@ -138,394 +108,6 @@ export class ChatGPTPuppeteer extends Puppeteer {
|
|||
}
|
||||
}
|
||||
|
||||
async init () {
|
||||
// if (this.inited) {
|
||||
// return true
|
||||
// }
|
||||
logger.info('init chatgpt browser')
|
||||
try {
|
||||
// this.browser = await getBrowser({
|
||||
// captchaToken: this._captchaToken,
|
||||
// executablePath: this._executablePath
|
||||
// })
|
||||
this.browser = await this.getBrowser()
|
||||
this._page =
|
||||
(await this.browser.pages())[0] || (await this.browser.newPage())
|
||||
await maximizePage(this._page)
|
||||
this._page.on('request', this._onRequest.bind(this))
|
||||
this._page.on('response', this._onResponse.bind(this))
|
||||
// bypass cloudflare and login
|
||||
let preCookies = await redis.get('CHATGPT:RAW_COOKIES')
|
||||
if (preCookies) {
|
||||
await this._page.setCookie(...JSON.parse(preCookies))
|
||||
}
|
||||
// const url = this._page.url().replace(/\/$/, '')
|
||||
// bypass annoying popup modals
|
||||
await this._page.evaluateOnNewDocument(() => {
|
||||
window.localStorage.setItem('oai/apps/hasSeenOnboarding/chat', 'true')
|
||||
const chatGPTUpdateDates = ['2022-12-15', '2022-12-19', '2023-01-09', '2023-01-30', '2023-02-10']
|
||||
chatGPTUpdateDates.forEach(date => {
|
||||
window.localStorage.setItem(
|
||||
`oai/apps/hasSeenReleaseAnnouncement/${date}`,
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
await this._page.goto(chatUrl, {
|
||||
waitUntil: 'networkidle2'
|
||||
})
|
||||
let timeout = 30000
|
||||
try {
|
||||
while (timeout > 0 && (await this._page.title()).toLowerCase().indexOf('moment') > -1) {
|
||||
// if meet captcha
|
||||
if (Config['2captchaToken']) {
|
||||
await this._page.solveRecaptchas()
|
||||
}
|
||||
await common.sleep(300)
|
||||
timeout = timeout - 300
|
||||
}
|
||||
} catch (e) {
|
||||
// navigation后获取title会报错,报错说明已经在navigation了正合我意。
|
||||
}
|
||||
if (timeout < 0) {
|
||||
logger.error('wait for cloudflare navigation timeout. 可能遇见验证码')
|
||||
throw new Error('wait for cloudflare navigation timeout. 可能遇见验证码')
|
||||
}
|
||||
try {
|
||||
await this._page.waitForNavigation({ timeout: 3000 })
|
||||
} catch (e) {}
|
||||
|
||||
if (!await this.getIsAuthenticated()) {
|
||||
await redis.del('CHATGPT:RAW_COOKIES')
|
||||
logger.info('需要登录,准备进行自动化登录')
|
||||
await getOpenAIAuth({
|
||||
email: this._email,
|
||||
password: this._password,
|
||||
browser: this.browser,
|
||||
page: this._page,
|
||||
isGoogleLogin: this._isGoogleLogin
|
||||
})
|
||||
logger.info('登录完成')
|
||||
} else {
|
||||
logger.info('无需登录')
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.browser) {
|
||||
await this.browser.close()
|
||||
}
|
||||
|
||||
this.browser = null
|
||||
this._page = null
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
const url = this._page.url().replace(/\/$/, '')
|
||||
|
||||
if (url !== chatUrl) {
|
||||
await this._page.goto(chatUrl, {
|
||||
waitUntil: 'networkidle2'
|
||||
})
|
||||
}
|
||||
|
||||
// dismiss welcome modal (and other modals)
|
||||
do {
|
||||
const modalSelector = '[data-headlessui-state="open"]'
|
||||
|
||||
if (!(await this._page.$(modalSelector))) {
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
await this._page.click(`${modalSelector} button:last-child`)
|
||||
} catch (err) {
|
||||
// "next" button not found in welcome modal
|
||||
break
|
||||
}
|
||||
|
||||
await common.sleep(300)
|
||||
} while (true)
|
||||
|
||||
if (!await this.getIsAuthenticated()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this._minimize) {
|
||||
await minimizePage(this._page)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
_onRequest = (request) => {
|
||||
const url = request.url()
|
||||
if (!isRelevantRequest(url)) {
|
||||
return
|
||||
}
|
||||
|
||||
const method = request.method()
|
||||
let body
|
||||
|
||||
if (method === 'POST') {
|
||||
body = request.postData()
|
||||
|
||||
try {
|
||||
body = JSON.parse(body)
|
||||
} catch (_) {
|
||||
}
|
||||
|
||||
// if (url.endsWith('/conversation') && typeof body === 'object') {
|
||||
// const conversationBody: types.ConversationJSONBody = body
|
||||
// const conversationId = conversationBody.conversation_id
|
||||
// const parentMessageId = conversationBody.parent_message_id
|
||||
// const messageId = conversationBody.messages?.[0]?.id
|
||||
// const prompt = conversationBody.messages?.[0]?.content?.parts?.[0]
|
||||
|
||||
// // TODO: store this info for the current sendMessage request
|
||||
// }
|
||||
}
|
||||
|
||||
if (this._debug) {
|
||||
console.log('\nrequest', {
|
||||
url,
|
||||
method,
|
||||
headers: request.headers(),
|
||||
body
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_onResponse = async (response) => {
|
||||
const request = response.request()
|
||||
|
||||
const url = response.url()
|
||||
if (!isRelevantRequest(url)) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = response.status()
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch (_) {
|
||||
}
|
||||
|
||||
if (this._debug) {
|
||||
console.log('\nresponse', {
|
||||
url,
|
||||
ok: response.ok(),
|
||||
status,
|
||||
statusText: response.statusText(),
|
||||
headers: response.headers(),
|
||||
body,
|
||||
request: {
|
||||
method: request.method(),
|
||||
headers: request.headers(),
|
||||
body: request.postData()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (url.endsWith('/conversation')) {
|
||||
if (status === 403) {
|
||||
await this.handle403Error()
|
||||
}
|
||||
} else if (url.endsWith('api/auth/session')) {
|
||||
if (status === 403) {
|
||||
await this.handle403Error()
|
||||
} else {
|
||||
const session = body
|
||||
if (session?.accessToken) {
|
||||
this._accessToken = session.accessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handle403Error () {
|
||||
console.log(`ChatGPT "${this._email}" session expired; refreshing...`)
|
||||
try {
|
||||
await maximizePage(this._page)
|
||||
await this._page.reload({
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: Config.chromeTimeoutMS // 2 minutes
|
||||
})
|
||||
if (this._minimize) {
|
||||
await minimizePage(this._page)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`ChatGPT "${this._email}" error refreshing session`,
|
||||
err.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getIsAuthenticated () {
|
||||
try {
|
||||
const inputBox = await this._getInputBox()
|
||||
return !!inputBox
|
||||
} catch (err) {
|
||||
// can happen when navigating during login
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage (
|
||||
message,
|
||||
opts = {}
|
||||
) {
|
||||
const {
|
||||
conversationId,
|
||||
parentMessageId = uuidv4(),
|
||||
messageId = uuidv4(),
|
||||
action = 'next',
|
||||
// TODO
|
||||
timeoutMs,
|
||||
// onProgress,
|
||||
onConversationResponse
|
||||
} = opts
|
||||
|
||||
const inputBox = await this._getInputBox()
|
||||
if (!inputBox || !this._accessToken) {
|
||||
console.log(`chatgpt re-authenticating ${this._email}`)
|
||||
let isAuthenticated = false
|
||||
|
||||
try {
|
||||
isAuthenticated = await this.init()
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`chatgpt error re-authenticating ${this._email}`,
|
||||
err.toString()
|
||||
)
|
||||
throw err
|
||||
}
|
||||
let timeout = 100000
|
||||
if (isAuthenticated) {
|
||||
while (!this._accessToken) {
|
||||
// wait for async response hook result
|
||||
await common.sleep(300)
|
||||
timeout = timeout - 300
|
||||
if (timeout < 0) {
|
||||
const error = new Error('Not signed in')
|
||||
error.statusCode = 401
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else if (!this._accessToken) {
|
||||
const error = new Error('Not signed in')
|
||||
error.statusCode = 401
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const url = 'https://chat.openai.com/backend-api/conversation'
|
||||
const body = {
|
||||
action,
|
||||
messages: [
|
||||
{
|
||||
id: messageId,
|
||||
role: 'user',
|
||||
content: {
|
||||
content_type: 'text',
|
||||
parts: [message]
|
||||
}
|
||||
}
|
||||
],
|
||||
model: Config.plus ? Config.useGPT4 ? 'gpt-4' : 'text-davinci-002-render-sha' : 'text-davinci-002-render-sha',
|
||||
parent_message_id: parentMessageId
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
body.conversation_id = conversationId
|
||||
}
|
||||
|
||||
// console.log('>>> EVALUATE', url, this._accessToken, body)
|
||||
const result = await this._page.evaluate(
|
||||
browserPostEventStream,
|
||||
url,
|
||||
this._accessToken,
|
||||
body,
|
||||
timeoutMs
|
||||
)
|
||||
// console.log('<<< EVALUATE', result)
|
||||
|
||||
if (result.error) {
|
||||
const error = new Error(result.error.message)
|
||||
error.statusCode = result.error.statusCode
|
||||
error.statusText = result.error.statusText
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
await this.handle403Error()
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
// TODO: support sending partial response events
|
||||
if (onConversationResponse) {
|
||||
onConversationResponse(result.conversationResponse)
|
||||
}
|
||||
|
||||
return {
|
||||
text: result.response,
|
||||
conversationId: result.conversationResponse.conversation_id,
|
||||
id: messageId,
|
||||
parentMessageId
|
||||
}
|
||||
|
||||
// const lastMessage = await this.getLastMessage()
|
||||
|
||||
// await inputBox.focus()
|
||||
// const paragraphs = message.split('\n')
|
||||
// for (let i = 0; i < paragraphs.length; i++) {
|
||||
// await inputBox.type(paragraphs[i], { delay: 0 })
|
||||
// if (i < paragraphs.length - 1) {
|
||||
// await this._page.keyboard.down('Shift')
|
||||
// await inputBox.press('Enter')
|
||||
// await this._page.keyboard.up('Shift')
|
||||
// } else {
|
||||
// await inputBox.press('Enter')
|
||||
// }
|
||||
// }
|
||||
|
||||
// const responseP = new Promise<string>(async (resolve, reject) => {
|
||||
// try {
|
||||
// do {
|
||||
// await common.sleep(1000)
|
||||
|
||||
// // TODO: this logic needs some work because we can have repeat messages...
|
||||
// const newLastMessage = await this.getLastMessage()
|
||||
// if (
|
||||
// newLastMessage &&
|
||||
// lastMessage?.toLowerCase() !== newLastMessage?.toLowerCase()
|
||||
// ) {
|
||||
// return resolve(newLastMessage)
|
||||
// }
|
||||
// } while (true)
|
||||
// } catch (err) {
|
||||
// return reject(err)
|
||||
// }
|
||||
// })
|
||||
|
||||
// if (timeoutMs) {
|
||||
// return pTimeout(responseP, {
|
||||
// milliseconds: timeoutMs
|
||||
// })
|
||||
// } else {
|
||||
// return responseP
|
||||
// }
|
||||
}
|
||||
|
||||
async resetThread () {
|
||||
try {
|
||||
await this._page.click('nav > a:nth-child(1)')
|
||||
} catch (err) {
|
||||
// ignore for now
|
||||
}
|
||||
}
|
||||
|
||||
async close () {
|
||||
if (this.browser) {
|
||||
await this.browser.close()
|
||||
|
|
@ -533,510 +115,6 @@ export class ChatGPTPuppeteer extends Puppeteer {
|
|||
this._page = null
|
||||
this.browser = null
|
||||
}
|
||||
|
||||
protected
|
||||
|
||||
async _getInputBox () {
|
||||
// [data-id="root"]
|
||||
return this._page?.$('textarea')
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChatGPTPuppeteer()
|
||||
|
||||
export async function minimizePage (page) {
|
||||
const session = await page.target().createCDPSession()
|
||||
const goods = await session.send('Browser.getWindowForTarget')
|
||||
const { windowId } = goods
|
||||
await session.send('Browser.setWindowBounds', {
|
||||
windowId,
|
||||
bounds: { windowState: 'minimized' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function maximizePage (page) {
|
||||
const session = await page.target().createCDPSession()
|
||||
const goods = await session.send('Browser.getWindowForTarget')
|
||||
const { windowId } = goods
|
||||
await session.send('Browser.setWindowBounds', {
|
||||
windowId,
|
||||
bounds: { windowState: 'normal' }
|
||||
})
|
||||
}
|
||||
|
||||
export function isRelevantRequest (url) {
|
||||
let pathname
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
pathname = parsedUrl.pathname
|
||||
url = parsedUrl.toString()
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://chat.openai.com')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!pathname.startsWith('/backend-api/') &&
|
||||
!pathname.startsWith('/api/auth/session')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (pathname.endsWith('backend-api/moderations')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is injected into the ChatGPT webapp page using puppeteer. It
|
||||
* has to be fully self-contained, so we copied a few third-party sources and
|
||||
* included them in here.
|
||||
*/
|
||||
export async function browserPostEventStream (
|
||||
url,
|
||||
accessToken,
|
||||
body,
|
||||
timeoutMs
|
||||
) {
|
||||
// Workaround for https://github.com/esbuild-kit/tsx/issues/113
|
||||
globalThis.__name = () => undefined
|
||||
|
||||
const BOM = [239, 187, 191]
|
||||
|
||||
let conversationResponse
|
||||
let conversationId = body?.conversation_id
|
||||
let messageId = body?.messages?.[0]?.id
|
||||
let response = ''
|
||||
|
||||
try {
|
||||
console.log('browserPostEventStream', url, accessToken, body)
|
||||
|
||||
let abortController = null
|
||||
if (timeoutMs) {
|
||||
abortController = new AbortController()
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
signal: abortController?.signal,
|
||||
headers: {
|
||||
accept: 'text/event-stream',
|
||||
'x-openai-assistant-app-id': '',
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('browserPostEventStream response', res)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: {
|
||||
message: `ChatGPTAPI error ${res.status || res.statusText}`,
|
||||
statusCode: res.status,
|
||||
statusText: res.statusText
|
||||
},
|
||||
response: null,
|
||||
conversationId,
|
||||
messageId
|
||||
}
|
||||
}
|
||||
|
||||
const responseP = new Promise(
|
||||
async (resolve, reject) => {
|
||||
function onMessage (data) {
|
||||
if (data === '[DONE]') {
|
||||
return resolve({
|
||||
error: null,
|
||||
response,
|
||||
conversationId,
|
||||
messageId,
|
||||
conversationResponse
|
||||
})
|
||||
}
|
||||
try {
|
||||
const _checkJson = JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.log('warning: parse error.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const convoResponseEvent =
|
||||
JSON.parse(data)
|
||||
conversationResponse = convoResponseEvent
|
||||
if (convoResponseEvent.conversation_id) {
|
||||
conversationId = convoResponseEvent.conversation_id
|
||||
}
|
||||
|
||||
if (convoResponseEvent.message?.id) {
|
||||
messageId = convoResponseEvent.message.id
|
||||
}
|
||||
|
||||
const partialResponse =
|
||||
convoResponseEvent.message?.content?.parts?.[0]
|
||||
if (partialResponse) {
|
||||
response = partialResponse
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('fetchSSE onMessage unexpected error', err)
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
const parser = createParser((event) => {
|
||||
if (event.type === 'event') {
|
||||
onMessage(event.data)
|
||||
}
|
||||
})
|
||||
|
||||
for await (const chunk of streamAsyncIterable(res.body)) {
|
||||
const str = new TextDecoder().decode(chunk)
|
||||
parser.feed(str)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (timeoutMs) {
|
||||
if (abortController) {
|
||||
// This will be called when a timeout occurs in order for us to forcibly
|
||||
// ensure that the underlying HTTP request is aborted.
|
||||
responseP.cancel = () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
console.log({ pTimeout })
|
||||
return await pTimeout(responseP, {
|
||||
milliseconds: timeoutMs,
|
||||
message: 'ChatGPT timed out waiting for response'
|
||||
})
|
||||
} else {
|
||||
return await responseP
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessageL = err.toString().toLowerCase()
|
||||
|
||||
if (
|
||||
response &&
|
||||
(errMessageL === 'error: typeerror: terminated' ||
|
||||
errMessageL === 'typeerror: terminated')
|
||||
) {
|
||||
// OpenAI sometimes forcefully terminates the socket from their end before
|
||||
// the HTTP request has resolved cleanly. In my testing, these cases tend to
|
||||
// happen when OpenAI has already send the last `response`, so we can ignore
|
||||
// the `fetch` error in this case.
|
||||
return {
|
||||
error: null,
|
||||
response,
|
||||
conversationId,
|
||||
messageId,
|
||||
conversationResponse
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
message: err.toString(),
|
||||
statusCode: err.statusCode || err.status || err.response?.statusCode,
|
||||
statusText: err.statusText || err.response?.statusText
|
||||
},
|
||||
response: null,
|
||||
conversationId,
|
||||
messageId,
|
||||
conversationResponse
|
||||
}
|
||||
}
|
||||
// async function pTimeout (promise, option) {
|
||||
// return await pTimeout(promise, option)
|
||||
// }
|
||||
async function * streamAsyncIterable (stream) {
|
||||
const reader = stream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
yield value
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
// @see https://github.com/rexxars/eventsource-parser
|
||||
function createParser (onParse) {
|
||||
// Processing state
|
||||
let isFirstChunk
|
||||
let buffer
|
||||
let startingPosition
|
||||
let startingFieldLength
|
||||
|
||||
// Event state
|
||||
let eventId
|
||||
let eventName
|
||||
let data
|
||||
|
||||
reset()
|
||||
return { feed, reset }
|
||||
|
||||
function reset () {
|
||||
isFirstChunk = true
|
||||
buffer = ''
|
||||
startingPosition = 0
|
||||
startingFieldLength = -1
|
||||
|
||||
eventId = undefined
|
||||
eventName = undefined
|
||||
data = ''
|
||||
}
|
||||
|
||||
function feed (chunk) {
|
||||
buffer = buffer ? buffer + chunk : chunk
|
||||
|
||||
// Strip any UTF8 byte order mark (BOM) at the start of the stream.
|
||||
// Note that we do not strip any non - UTF8 BOM, as eventsource streams are
|
||||
// always decoded as UTF8 as per the specification.
|
||||
if (isFirstChunk && hasBom(buffer)) {
|
||||
buffer = buffer.slice(BOM.length)
|
||||
}
|
||||
|
||||
isFirstChunk = false
|
||||
|
||||
// Set up chunk-specific processing state
|
||||
const length = buffer.length
|
||||
let position = 0
|
||||
let discardTrailingNewline = false
|
||||
|
||||
// Read the current buffer byte by byte
|
||||
while (position < length) {
|
||||
// EventSource allows for carriage return + line feed, which means we
|
||||
// need to ignore a linefeed character if the previous character was a
|
||||
// carriage return
|
||||
// @todo refactor to reduce nesting, consider checking previous byte?
|
||||
// @todo but consider multiple chunks etc
|
||||
if (discardTrailingNewline) {
|
||||
if (buffer[position] === '\n') {
|
||||
++position
|
||||
}
|
||||
discardTrailingNewline = false
|
||||
}
|
||||
|
||||
let lineLength = -1
|
||||
let fieldLength = startingFieldLength
|
||||
let character
|
||||
|
||||
for (
|
||||
let index = startingPosition;
|
||||
lineLength < 0 && index < length;
|
||||
++index
|
||||
) {
|
||||
character = buffer[index]
|
||||
if (character === ':' && fieldLength < 0) {
|
||||
fieldLength = index - position
|
||||
} else if (character === '\r') {
|
||||
discardTrailingNewline = true
|
||||
lineLength = index - position
|
||||
} else if (character === '\n') {
|
||||
lineLength = index - position
|
||||
}
|
||||
}
|
||||
|
||||
if (lineLength < 0) {
|
||||
startingPosition = length - position
|
||||
startingFieldLength = fieldLength
|
||||
break
|
||||
} else {
|
||||
startingPosition = 0
|
||||
startingFieldLength = -1
|
||||
}
|
||||
|
||||
parseEventStreamLine(buffer, position, fieldLength, lineLength)
|
||||
|
||||
position += lineLength + 1
|
||||
}
|
||||
|
||||
if (position === length) {
|
||||
// If we consumed the entire buffer to read the event, reset the buffer
|
||||
buffer = ''
|
||||
} else if (position > 0) {
|
||||
// If there are bytes left to process, set the buffer to the unprocessed
|
||||
// portion of the buffer only
|
||||
buffer = buffer.slice(position)
|
||||
}
|
||||
}
|
||||
|
||||
function parseEventStreamLine (
|
||||
lineBuffer,
|
||||
index,
|
||||
fieldLength,
|
||||
lineLength
|
||||
) {
|
||||
if (lineLength === 0) {
|
||||
// We reached the last line of this event
|
||||
if (data.length > 0) {
|
||||
onParse({
|
||||
type: 'event',
|
||||
id: eventId,
|
||||
event: eventName || undefined,
|
||||
data: data.slice(0, -1) // remove trailing newline
|
||||
})
|
||||
|
||||
data = ''
|
||||
eventId = undefined
|
||||
}
|
||||
eventName = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const noValue = fieldLength < 0
|
||||
const field = lineBuffer.slice(
|
||||
index,
|
||||
index + (noValue ? lineLength : fieldLength)
|
||||
)
|
||||
let step = 0
|
||||
|
||||
if (noValue) {
|
||||
step = lineLength
|
||||
} else if (lineBuffer[index + fieldLength + 1] === ' ') {
|
||||
step = fieldLength + 2
|
||||
} else {
|
||||
step = fieldLength + 1
|
||||
}
|
||||
|
||||
const position = index + step
|
||||
const valueLength = lineLength - step
|
||||
const value = lineBuffer
|
||||
.slice(position, position + valueLength)
|
||||
.toString()
|
||||
|
||||
if (field === 'data') {
|
||||
data += value ? `${value}\n` : '\n'
|
||||
} else if (field === 'event') {
|
||||
eventName = value
|
||||
} else if (field === 'id' && !value.includes('\u0000')) {
|
||||
eventId = value
|
||||
} else if (field === 'retry') {
|
||||
const retry = parseInt(value, 10)
|
||||
if (!Number.isNaN(retry)) {
|
||||
onParse({ type: 'reconnect-interval', value: retry })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasBom (buffer) {
|
||||
return BOM.every(
|
||||
(charCode, index) => buffer.charCodeAt(index) === charCode
|
||||
)
|
||||
}
|
||||
|
||||
// @see https://github.com/sindresorhus/p-timeout
|
||||
function pTimeout (
|
||||
promise,
|
||||
options
|
||||
) {
|
||||
const {
|
||||
milliseconds,
|
||||
fallback,
|
||||
message,
|
||||
customTimers = { setTimeout, clearTimeout }
|
||||
} = options
|
||||
|
||||
let timer
|
||||
|
||||
const cancelablePromise = new Promise((resolve, reject) => {
|
||||
if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) {
|
||||
throw new TypeError(
|
||||
`Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\``
|
||||
)
|
||||
}
|
||||
|
||||
if (milliseconds === Number.POSITIVE_INFINITY) {
|
||||
resolve(promise)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
const { signal } = options
|
||||
if (signal.aborted) {
|
||||
reject(getAbortedReason(signal))
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(getAbortedReason(signal))
|
||||
})
|
||||
}
|
||||
|
||||
timer = customTimers.setTimeout.call(
|
||||
undefined,
|
||||
() => {
|
||||
if (fallback) {
|
||||
try {
|
||||
resolve(fallback())
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: `Promise timed out after ${milliseconds} milliseconds`
|
||||
const timeoutError =
|
||||
message instanceof Error ? message : new Error(errorMessage)
|
||||
|
||||
if (typeof promise.cancel === 'function') {
|
||||
promise.cancel()
|
||||
}
|
||||
|
||||
reject(timeoutError)
|
||||
},
|
||||
milliseconds
|
||||
)
|
||||
;(async () => {
|
||||
try {
|
||||
resolve(await promise)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
} finally {
|
||||
customTimers.clearTimeout.call(undefined, timer)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
cancelablePromise.clear = () => {
|
||||
customTimers.clearTimeout.call(undefined, timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
return cancelablePromise
|
||||
}
|
||||
/**
|
||||
TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18.
|
||||
*/
|
||||
function getAbortedReason (signal) {
|
||||
const reason =
|
||||
signal.reason === undefined
|
||||
? getDOMException('This operation was aborted.')
|
||||
: signal.reason
|
||||
|
||||
return reason instanceof Error ? reason : getDOMException(reason)
|
||||
}
|
||||
/**
|
||||
TODO: Remove AbortError and just throw DOMException when targeting Node 18.
|
||||
*/
|
||||
function getDOMException (errorMessage) {
|
||||
return globalThis.DOMException === undefined
|
||||
? new Error(errorMessage)
|
||||
: new DOMException(errorMessage)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue