dsh-lcx-codex 0.3.3 → 0.4.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/transport.js CHANGED
@@ -1,396 +1,75 @@
1
- import { setTimeout as delay } from 'node:timers/promises'
2
-
3
- const MAX_RETRY_DELAY_MS = 30_000
4
- const combinedSignalStates = new WeakMap()
5
-
6
- export class LcxHttpError extends Error {
7
- constructor(message, { code = 'LCX_HTTP_ERROR', status, retryable = false, cause } = {}) {
8
- super(message, cause === undefined ? undefined : { cause })
9
- this.name = 'LcxHttpError'
10
- this.code = code
11
- this.status = status
12
- this.retryable = retryable
13
- }
14
- }
1
+ const DEFAULT_TIMEOUT_MS = 300_000
2
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
15
3
 
16
4
  export function abortIfNeeded(signal) {
17
- if (signal?.aborted) {
18
- throw signal.reason instanceof Error ? signal.reason : new Error('request aborted')
19
- }
20
- }
21
-
22
- function responseDetail(body) {
23
- try {
24
- const parsed = JSON.parse(body)
25
- if (typeof parsed?.error === 'string') return parsed.error
26
- if (typeof parsed?.error?.message === 'string') return parsed.error.message
27
- if (typeof parsed?.message === 'string') return parsed.message
28
- } catch {
29
- // The status and a bounded body preview are enough when the gateway did not return JSON.
30
- }
31
- return String(body ?? '').replace(/\s+/gu, ' ').trim().slice(0, 500)
32
- }
33
-
34
- function retryableStatus(status) {
35
- return status === 429 || (status >= 500 && status <= 599)
36
- }
37
-
38
- function combinedSignal(signal, timeoutMs) {
39
- const controller = new AbortController()
40
- let timer
41
- const abort = (reason) => {
42
- if (controller.signal.aborted) return
43
- if (timer !== undefined) globalThis.clearTimeout(timer)
44
- combinedSignalStates.delete(controller.signal)
45
- signal?.removeEventListener('abort', onCallerAbort)
46
- controller.abort(reason)
47
- }
48
- const onCallerAbort = () => abort(signal.reason)
49
- if (Number.isFinite(timeoutMs)) {
50
- timer = globalThis.setTimeout(() => {
51
- abort(new DOMException(`The operation was aborted due to timeout`, 'TimeoutError'))
52
- }, Math.max(0, timeoutMs))
53
- timer.unref?.()
54
- }
55
- combinedSignalStates.set(controller.signal, { timer, callerSignal: signal, onCallerAbort })
56
- if (signal?.aborted) abort(signal.reason)
57
- else signal?.addEventListener('abort', onCallerAbort, { once: true })
58
- return controller.signal
59
- }
60
-
61
- function holdSignal(signal) {
62
- combinedSignalStates.get(signal)?.timer?.ref?.()
63
- }
64
-
65
- function releaseSignal(signal) {
66
- const state = combinedSignalStates.get(signal)
67
- if (!state) return
68
- if (state.timer !== undefined) globalThis.clearTimeout(state.timer)
69
- state.callerSignal?.removeEventListener('abort', state.onCallerAbort)
70
- combinedSignalStates.delete(signal)
71
- }
72
-
73
- function retryAfterMilliseconds(response) {
74
- const value = response?.headers?.get?.('retry-after')
75
- if (!value) return undefined
76
- const seconds = Number(value)
77
- if (Number.isFinite(seconds) && seconds >= 0) return Math.min(MAX_RETRY_DELAY_MS, Math.ceil(seconds * 1000))
78
- const date = Date.parse(value)
79
- return Number.isFinite(date) ? Math.min(MAX_RETRY_DELAY_MS, Math.max(0, date - Date.now())) : undefined
80
- }
81
-
82
- function timeoutError(timeoutMs, cause) {
83
- return new LcxHttpError(`LCX request timed out after ${timeoutMs} ms`, { code: 'LCX_TIMEOUT', retryable: true, cause })
84
- }
85
-
86
- function abortRequestIfNeeded(requestSignal, callerSignal, timeoutMs) {
87
- abortIfNeeded(callerSignal)
88
- if (!requestSignal?.aborted) return
89
- if (requestSignal.reason?.name === 'TimeoutError') throw timeoutError(timeoutMs, requestSignal.reason)
90
- throw requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted')
91
- }
92
-
93
- function retryWaitMilliseconds(error, baseDelayMs, attempt) {
94
- const retryAfter = Number(error?.retryAfterMs)
95
- if (Number.isFinite(retryAfter) && retryAfter >= 0) return Math.min(MAX_RETRY_DELAY_MS, retryAfter)
96
- const exponential = baseDelayMs * (2 ** (attempt - 1))
97
- return Number.isFinite(exponential) && exponential >= 0 ? Math.min(MAX_RETRY_DELAY_MS, exponential) : MAX_RETRY_DELAY_MS
98
- }
99
-
100
- async function readLimitedText(response, maxResponseBytes, signal, label) {
101
- if (!response?.body) return ''
102
- const reader = response.body.getReader()
103
- const decoder = new TextDecoder()
104
- const parts = []
105
- let bytes = 0
106
- const cancel = () => { reader.cancel(signal?.reason).catch(() => undefined) }
107
- try {
108
- signal?.addEventListener('abort', cancel, { once: true })
109
- while (true) {
110
- abortIfNeeded(signal)
111
- const result = await reader.read()
112
- abortIfNeeded(signal)
113
- if (result.done) break
114
- bytes += result.value.byteLength
115
- if (bytes > maxResponseBytes) {
116
- throw new LcxHttpError(`LCX ${label} response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
117
- }
118
- parts.push(decoder.decode(result.value, { stream: true }))
119
- }
120
- parts.push(decoder.decode())
121
- return parts.join('')
122
- } finally {
123
- signal?.removeEventListener('abort', cancel)
124
- await reader.cancel().catch(() => undefined)
125
- }
126
- }
127
-
128
- function normalizeReadError(error, requestSignal, timeoutMs) {
129
- if (requestSignal?.aborted && requestSignal.reason?.name === 'TimeoutError') return timeoutError(timeoutMs, error)
130
- return error
131
- }
132
-
133
- function abortableResponse(response, requestSignal, callerSignal, timeoutMs, ownsRequestSignal = false) {
134
- if (!response?.body || !requestSignal) return response
135
- const source = response.body
136
- const reader = source.getReader()
137
- let abortError
138
- let cleanedUp = false
139
- let pendingReadReject
140
- let onAbort
141
- const cleanup = () => {
142
- if (cleanedUp) return
143
- cleanedUp = true
144
- requestSignal.removeEventListener('abort', onAbort)
145
- if (ownsRequestSignal) releaseSignal(requestSignal)
146
- }
147
- const abort = () => {
148
- if (abortError) return
149
- abortError = callerSignal?.aborted
150
- ? (callerSignal.reason instanceof Error ? callerSignal.reason : new Error('request aborted'))
151
- : requestSignal.reason?.name === 'TimeoutError'
152
- ? timeoutError(timeoutMs, requestSignal.reason)
153
- : (requestSignal.reason instanceof Error ? requestSignal.reason : new Error('request aborted'))
154
- cleanup()
155
- pendingReadReject?.(abortError)
156
- reader.cancel(abortError).catch(() => undefined)
157
- }
158
- onAbort = abort
159
- const stream = new ReadableStream({
160
- start(controller) {
161
- if (requestSignal.aborted) {
162
- abort()
163
- controller.error(abortError)
164
- } else {
165
- requestSignal.addEventListener('abort', onAbort, { once: true })
166
- }
167
- },
168
- async pull(controller) {
169
- if (abortError) return
170
- try {
171
- const result = await new Promise((resolve, reject) => {
172
- pendingReadReject = reject
173
- reader.read().then(resolve, reject)
174
- })
175
- pendingReadReject = undefined
176
- if (abortError) return
177
- if (result.done) {
178
- cleanup()
179
- controller.close()
180
- } else {
181
- controller.enqueue(result.value)
182
- }
183
- } catch (error) {
184
- pendingReadReject = undefined
185
- cleanup()
186
- controller.error(abortError ?? error)
187
- }
188
- },
189
- cancel(reason) {
190
- cleanup()
191
- reader.cancel(reason).catch(() => undefined)
192
- },
193
- })
194
- return new Response(stream, {
195
- status: response.status,
196
- statusText: response.statusText,
197
- headers: response.headers,
198
- })
199
- }
200
-
201
- export async function fetchJson(url, body, headers, signal, timeoutMs, options = {}) {
202
- abortIfNeeded(signal)
203
- const maxResponseBytes = options.maxResponseBytes ?? 4 * 1024 * 1024
204
- const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
205
- const ownsRequestSignal = !options.requestSignal
206
- if (ownsRequestSignal) holdSignal(requestSignal)
207
- try {
208
- abortRequestIfNeeded(requestSignal, signal, timeoutMs)
209
- let serializedBody
210
- try {
211
- serializedBody = JSON.stringify(body)
212
- } catch (error) {
213
- throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
214
- }
215
- let response
216
- try {
217
- response = await fetch(url, {
218
- method: 'POST',
219
- redirect: 'error',
220
- headers: { accept: 'application/json', 'content-type': 'application/json', ...headers },
221
- body: serializedBody,
222
- signal: requestSignal,
223
- })
224
- } catch (error) {
225
- abortIfNeeded(signal)
226
- if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
227
- if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
228
- throw error
229
- }
230
-
231
- const contentLength = Number(response.headers.get('content-length') ?? 0)
232
- if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
233
- throw new LcxHttpError(`LCX response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
234
- }
235
-
236
- let text
237
- try {
238
- text = await readLimitedText(response, maxResponseBytes, requestSignal, 'JSON')
239
- } catch (error) {
240
- throw normalizeReadError(error, requestSignal, timeoutMs)
241
- }
242
- if (!response.ok) {
243
- const detail = responseDetail(text)
244
- const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
245
- code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
246
- status: response.status,
247
- retryable: retryableStatus(response.status),
248
- })
249
- error.retryAfterMs = retryAfterMilliseconds(response)
250
- throw error
251
- }
252
- try {
253
- return JSON.parse(text)
254
- } catch (error) {
255
- throw new LcxHttpError(`LCX returned a non-JSON response: ${String(error)}`, { code: 'LCX_INVALID_JSON', status: response.status, cause: error })
256
- }
257
- }
258
- finally {
259
- if (ownsRequestSignal) releaseSignal(requestSignal)
260
- }
261
- }
262
-
263
- export async function fetchSse(url, body, headers, signal, timeoutMs, options = {}) {
264
- abortIfNeeded(signal)
265
- const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024
266
- const requestSignal = options.requestSignal ?? combinedSignal(signal, timeoutMs)
267
- const ownsRequestSignal = !options.requestSignal
268
- if (ownsRequestSignal) holdSignal(requestSignal)
269
- let handedOff = false
270
- try {
271
- abortRequestIfNeeded(requestSignal, signal, timeoutMs)
272
- let serializedBody
273
- try {
274
- serializedBody = JSON.stringify(body)
275
- } catch (error) {
276
- throw new LcxHttpError(`LCX request body is not serializable: ${String(error)}`, { code: 'LCX_INVALID_REQUEST', cause: error })
277
- }
278
- let response
279
- try {
280
- response = await fetch(url, {
281
- method: 'POST',
282
- redirect: 'error',
283
- headers: { accept: 'text/event-stream', 'content-type': 'application/json', ...headers },
284
- body: serializedBody,
285
- signal: requestSignal,
286
- })
287
- } catch (error) {
5
+ if (signal?.aborted) throw signal.reason ?? Object.assign(new Error('request aborted'), { code: 'LCX_ABORTED' })
6
+ }
7
+ function makeError(message, code, extra = {}) { const error = new Error(message); error.code = code; Object.assign(error, extra); return error }
8
+ function retryableStatus(status) { return status === 408 || status === 409 || status === 425 || status === 429 || (status >= 500 && status <= 599) }
9
+ function delayFromHeaders(headers, attempt) {
10
+ const millis = Number(headers?.get?.('retry-after-ms')); if (Number.isFinite(millis) && millis >= 0) return Math.min(millis, 30_000)
11
+ const retryAfter = headers?.get?.('retry-after'); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000); const date = Date.parse(retryAfter); if (!Number.isNaN(date)) return Math.min(Math.max(0, date - Date.now()), 30_000) }
12
+ return Math.min(10_000, 500 * 2 ** Math.max(0, attempt - 1))
13
+ }
14
+ async function sleep(ms, signal) { if (ms <= 0) return; await new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const timer = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason) }, { once: true }) }) }
15
+ function combinedSignal(signal, timeoutMs) { if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) return signal; const timeout = AbortSignal.timeout(timeoutMs); return signal ? AbortSignal.any([signal, timeout]) : timeout }
16
+ async function readLimited(response, maxBytes = DEFAULT_MAX_BYTES) { const buffer = new Uint8Array(await response.arrayBuffer()); if (buffer.byteLength > maxBytes) throw makeError(`response exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE', { status: response.status }); return new TextDecoder().decode(buffer) }
17
+
18
+ export async function fetchJsonWithRetry(url, body, headers = {}, signal, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
19
+ const maxAttempts = Math.max(1, Math.min(6, Number(options.maxAttempts ?? 3))); const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_BYTES; let last
20
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
288
21
  abortIfNeeded(signal)
289
- if (requestSignal?.aborted) abortRequestIfNeeded(requestSignal, signal, timeoutMs)
290
- if (error?.name === 'TimeoutError') throw timeoutError(timeoutMs, error)
291
- throw error
292
- }
293
-
294
- const contentLength = Number(response.headers.get('content-length') ?? 0)
295
- if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
296
- throw new LcxHttpError(`LCX SSE response exceeds ${maxResponseBytes} bytes`, { code: 'LCX_RESPONSE_TOO_LARGE', status: response.status })
297
- }
298
- if (!response.ok) {
299
- let text
300
22
  try {
301
- text = await readLimitedText(response, maxResponseBytes, requestSignal, 'SSE')
23
+ const requestSignal = combinedSignal(signal, timeoutMs)
24
+ const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, body: JSON.stringify(body), signal: requestSignal, redirect: 'error' })
25
+ const text = await readLimited(response, maxResponseBytes)
26
+ if (!response.ok) {
27
+ const retryable = retryableStatus(response.status)
28
+ const error = makeError(`HTTP ${response.status}`, retryable ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR', { status: response.status, retryable, requestId: response.headers.get('x-request-id') ?? response.headers.get('request-id') ?? undefined })
29
+ if (!error.retryable || attempt >= maxAttempts) throw error; last = error; await sleep(delayFromHeaders(response.headers, attempt), signal); continue
30
+ }
31
+ let parsed
32
+ try { parsed = text ? JSON.parse(text) : {} } catch (cause) { throw makeError(`invalid JSON response from ${url}`, 'LCX_INVALID_JSON', { status: response.status, cause }) }
33
+ return parsed
302
34
  } catch (error) {
303
- throw normalizeReadError(error, requestSignal, timeoutMs)
35
+ if (signal?.aborted) throw signal.reason ?? error
36
+ const retryable = error?.retryable === true || error?.code === 'LCX_HTTP_RETRYABLE' || error instanceof TypeError || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)
37
+ if (!retryable || attempt >= maxAttempts) { if (attempt >= maxAttempts && retryable) throw makeError(`request failed after ${maxAttempts} attempts`, 'LCX_RETRY_EXHAUSTED', { cause: error, retryable: true }); throw error }
38
+ last = error; await sleep(Math.min(10_000, 500 * 2 ** (attempt - 1)), signal)
304
39
  }
305
- const detail = responseDetail(text)
306
- const error = new LcxHttpError(`LCX request failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, {
307
- code: retryableStatus(response.status) ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR',
308
- status: response.status,
309
- retryable: retryableStatus(response.status),
310
- })
311
- error.retryAfterMs = retryAfterMilliseconds(response)
312
- throw error
313
- }
314
- if (!response.body) {
315
- throw new LcxHttpError('LCX SSE response has no body', { code: 'LCX_INVALID_SSE', status: response.status })
316
- }
317
- const wrapped = abortableResponse(response, requestSignal, signal, timeoutMs, ownsRequestSignal)
318
- handedOff = ownsRequestSignal
319
- return wrapped
320
- } finally {
321
- if (ownsRequestSignal && !handedOff) releaseSignal(requestSignal)
322
40
  }
41
+ throw last ?? makeError('request failed', 'LCX_REQUEST_FAILED')
323
42
  }
324
43
 
325
- async function waitForRetry(waitMs, operationSignal, callerSignal, timeoutMs) {
326
- abortRequestIfNeeded(operationSignal, callerSignal, timeoutMs)
327
- try {
328
- await delay(waitMs, undefined, { signal: operationSignal })
329
- } catch (error) {
330
- abortRequestIfNeeded(operationSignal, callerSignal, timeoutMs)
331
- throw error
332
- }
333
- }
334
-
335
- function transientNetworkError(error) {
336
- if (!error || error.name === 'AbortError' || error.name === 'TimeoutError') return false
337
- if (error instanceof LcxHttpError) return error.code === 'LCX_HTTP_RETRYABLE' || error.code === 'LCX_TIMEOUT'
338
- return error.retryable === true || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(error.code)
339
- }
340
-
341
- export async function fetchSseWithRetry(url, body, headers, signal, timeoutMs, options = {}) {
342
- const maxAttempts = Math.max(1, Math.min(options.maxAttempts ?? 3, 6))
343
- const baseDelayMs = Math.max(0, options.baseDelayMs ?? 250)
344
- const consume = options.consume
345
- if (typeof consume !== 'function') throw new TypeError('fetchSseWithRetry requires a consume callback')
346
- const operationSignal = combinedSignal(signal, timeoutMs)
347
- holdSignal(operationSignal)
348
- let attempt = 0
349
- try {
350
- while (attempt < maxAttempts) {
351
- attempt += 1
352
- try {
353
- abortRequestIfNeeded(operationSignal, signal, timeoutMs)
354
- const response = await fetchSse(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
355
- try {
356
- return await consume(response, { ...options, requestSignal: operationSignal })
357
- } finally {
358
- if (response.body) await response.body.cancel().catch(() => undefined)
359
- }
360
- } catch (error) {
361
- abortIfNeeded(signal)
362
- if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
363
- if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
364
- await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
44
+ export async function fetchSseWithRetry(url, body, headers = {}, signal, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
45
+ const maxAttempts = Math.max(1, Math.min(6, Number(options.maxAttempts ?? 3))); let last
46
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
47
+ abortIfNeeded(signal)
48
+ try {
49
+ const requestSignal = combinedSignal(signal, timeoutMs)
50
+ const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...headers }, body: JSON.stringify(body), signal: requestSignal, redirect: 'error' })
51
+ if (!response.ok) {
52
+ await readLimited(response, Math.min(options.maxResponseBytes ?? DEFAULT_MAX_BYTES, 512 * 1024))
53
+ const retryable = retryableStatus(response.status); const error = makeError(`HTTP ${response.status}`, retryable ? 'LCX_HTTP_RETRYABLE' : 'LCX_HTTP_ERROR', { status: response.status, retryable, requestId: response.headers.get('x-request-id') ?? response.headers.get('request-id') ?? undefined })
54
+ if (!retryable || attempt >= maxAttempts) throw error; last = error; await sleep(delayFromHeaders(response.headers, attempt), signal); continue
365
55
  }
56
+ if (typeof options.consume === 'function') return await options.consume(response, { requestSignal }); return response
57
+ } catch (error) {
58
+ if (signal?.aborted) throw signal.reason ?? error
59
+ const retryable = error?.retryable === true || error?.code === 'LCX_HTTP_RETRYABLE' || error instanceof TypeError || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT'].includes(error?.code)
60
+ if (!retryable || attempt >= maxAttempts) { if (attempt >= maxAttempts && retryable) throw makeError(`request failed after ${maxAttempts} attempts`, 'LCX_RETRY_EXHAUSTED', { cause: error, retryable: true }); throw error }
61
+ last = error; await sleep(Math.min(10_000, 500 * 2 ** (attempt - 1)), signal)
366
62
  }
367
- throw new LcxHttpError('LCX SSE retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
368
- } finally {
369
- releaseSignal(operationSignal)
370
63
  }
64
+ throw last ?? makeError('request failed', 'LCX_REQUEST_FAILED')
371
65
  }
372
66
 
373
- export async function fetchJsonWithRetry(url, body, headers, signal, timeoutMs, options = {}) {
374
- const maxAttempts = Math.max(1, Math.min(options.maxAttempts ?? 3, 6))
375
- const baseDelayMs = Math.max(0, options.baseDelayMs ?? 250)
376
- const operationSignal = combinedSignal(signal, timeoutMs)
377
- holdSignal(operationSignal)
378
- let attempt = 0
67
+ export async function consumeSse(response, onEvent, options = {}) {
68
+ if (!response?.body) throw makeError('SSE response has no body', 'LCX_INVALID_SSE')
69
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_BYTES; const reader = response.body.getReader(); const decoder = new TextDecoder(); let pending = ''; let dataLines = []; let bytes = 0
70
+ const dispatch = () => { if (dataLines.length === 0) return; const data = dataLines.join('\n'); dataLines = []; if (data === '[DONE]') return; let event; try { event = JSON.parse(data) } catch (cause) { throw makeError('malformed SSE JSON', 'LCX_INVALID_SSE', { cause }) } onEvent(event) }
379
71
  try {
380
- while (attempt < maxAttempts) {
381
- attempt += 1
382
- try {
383
- abortRequestIfNeeded(operationSignal, signal, timeoutMs)
384
- return await fetchJson(url, body, headers, signal, timeoutMs, { ...options, requestSignal: operationSignal })
385
- } catch (error) {
386
- abortIfNeeded(signal)
387
- if (operationSignal.aborted) abortRequestIfNeeded(operationSignal, signal, timeoutMs)
388
- if (!transientNetworkError(error) || attempt >= maxAttempts) throw error
389
- await waitForRetry(retryWaitMilliseconds(error, baseDelayMs, attempt), operationSignal, signal, timeoutMs)
390
- }
391
- }
392
- throw new LcxHttpError('LCX retry loop exhausted', { code: 'LCX_RETRY_EXHAUSTED' })
393
- } finally {
394
- releaseSignal(operationSignal)
395
- }
72
+ while (true) { abortIfNeeded(options.signal); const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > maxBytes) throw makeError(`SSE response exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE'); pending += decoder.decode(value, { stream: true }); let newline; while ((newline = pending.indexOf('\n')) >= 0) { const line = pending.slice(0, newline).replace(/\r$/u, ''); pending = pending.slice(newline + 1); if (line === '') dispatch(); else if (!line.startsWith(':') && line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /u, '')) } }
73
+ pending += decoder.decode(); if (pending.startsWith('data:')) dataLines.push(pending.slice(5).replace(/^ /u, '')); dispatch()
74
+ } finally { await reader.cancel().catch(() => undefined) }
396
75
  }
@@ -3,155 +3,16 @@ const CITATION_PATTERN = /cite([^]+)/gu
3
3
  const PAGE_LINE_PATTERN = /^L(\d+)(?:@P(\d+)(?:-(\d+))?)?:\s?(.*)$/u
4
4
  const EMBEDDED_PAGE_LINE_PATTERN = / (?=L\d+(?:@P\d+(?:-\d+)?)?:)/gu
5
5
  const TITLE_URL_PATTERN = /^(.*?)\s*\((https?:\/\/[^)]+)\)\s*$/u
6
-
7
- function pushUnique(target, value) {
8
- if (!target.includes(value)) target.push(value)
9
- }
10
-
11
- function pushLink(target, payload) {
12
- const [idValue, labelValue, domainValue] = payload.split('†')
13
- if (!/^\d+$/u.test(idValue ?? '') || !labelValue) return false
14
- const id = Number(idValue)
15
- if (!Number.isSafeInteger(id)) return false
16
- const label = labelValue.trim().slice(0, 500)
17
- const domain = domainValue?.trim().slice(0, 253)
18
- if (!label) return false
19
- const existing = target.find((link) => link.id === id)
20
- if (!existing) target.push({ id, label, ...(domain ? { domain } : {}) })
21
- else if (!existing.domain && domain) existing.domain = domain
22
- return true
23
- }
24
-
25
- function cleanCitations(value, references, links) {
26
- return String(value ?? '').replace(CITATION_PATTERN, (_match, payload) => {
27
- if (/^turn[\w-]+$/u.test(payload)) {
28
- pushUnique(references, payload)
29
- return ''
30
- }
31
- if (pushLink(links, payload)) return payload.split('†')[1]
32
- const separator = payload.indexOf('†')
33
- return separator < 0 ? payload : payload.slice(separator + 1)
34
- }).trim()
35
- }
36
-
37
- function metadataParts(value) {
38
- return value
39
- .replace(/^\[wordlim:\s*(\d+)\]\s*/u, '$1-word excerpt; ')
40
- .split(/;\s*/u)
41
- .map((part) => part.trim())
42
- .filter(Boolean)
43
- .map((part) => {
44
- const contentType = part.match(/^Content type:\s*(.+)$/u)
45
- if (contentType) return contentType[1] === 'text/html' ? 'HTML' : contentType[1] === 'application/pdf' ? 'PDF' : contentType[1]
46
- const totalLines = part.match(/^Total lines:\s*(\d+)$/u)
47
- if (totalLines) return `${totalLines[1]} lines`
48
- const pages = part.match(/^Number of pages:\s*(\d+)$/u)
49
- if (pages) return `${pages[1]} pages`
50
- return part
51
- })
52
- }
53
-
54
- function isMetadata(value) {
55
- return value.startsWith('[wordlim:') || /^(?:Published|Crawled|Content type|Source|Total lines|Number of pages):/u.test(value)
56
- }
57
-
58
- function parseLine(value, references, links) {
59
- const clean = cleanCitations(value, references, links)
60
- const pageLine = clean.match(PAGE_LINE_PATTERN)
61
- if (pageLine) {
62
- const text = pageLine[4] ?? ''
63
- const heading = text.match(/^(#{1,6})\s+(.+)$/u)
64
- return {
65
- line: Number(pageLine[1]),
66
- ...(pageLine[2] === undefined ? {} : { page: Number(pageLine[2]) }),
67
- ...(pageLine[3] === undefined ? {} : { pageEnd: Number(pageLine[3]) }),
68
- text: heading?.[2] ?? text,
69
- ...(heading ? { heading: heading[1].length } : {}),
70
- }
71
- }
72
- const heading = clean.match(/^(#{1,6})\s+(.+)$/u)
73
- return heading ? { text: heading[2], heading: heading[1].length } : { text: clean }
74
- }
75
-
76
- function parseBlock(value) {
77
- const references = []
78
- const links = []
79
- const rawLines = String(value ?? '')
80
- .replace(EMBEDDED_PAGE_LINE_PATTERN, '\n')
81
- .split(/\r?\n/u)
82
- .map((line) => line.trimEnd())
83
- while (rawLines[0]?.trim() === '') rawLines.shift()
84
- while (rawLines.at(-1)?.trim() === '') rawLines.pop()
85
- if (rawLines.length === 0) return undefined
86
-
87
- const firstLine = cleanCitations(rawLines[0], references, links)
88
- const header = firstLine.match(TITLE_URL_PATTERN)
89
- const titleOnly = firstLine.match(/^(.+?)\s*\(\)\s*$/u)
90
- let title = header?.[1]?.trim() || titleOnly?.[1]?.trim() || undefined
91
- const url = header?.[2]
92
- let bodyStart = header || titleOnly ? 1 : 0
93
- if (!header && /^\s*\([^)]*\)\s*$/u.test(firstLine)) bodyStart = 1
94
-
95
- const metadata = []
96
- while (bodyStart < rawLines.length) {
97
- const clean = cleanCitations(rawLines[bodyStart], references, links)
98
- if (!isMetadata(clean)) break
99
- metadata.push(...metadataParts(clean))
100
- bodyStart += 1
101
- }
102
- const lines = rawLines.slice(bodyStart).map((line) => parseLine(line, references, links))
103
- if (!title && url) {
104
- try { title = new URL(url).hostname } catch { title = url }
105
- }
106
- return {
107
- ...(title ? { title } : {}),
108
- ...(url ? { url } : {}),
109
- references,
110
- links,
111
- metadata,
112
- lines,
113
- }
114
- }
115
-
116
- export function parseWebRunOutput(output) {
117
- if (typeof output !== 'string' || output.length === 0) return []
118
- return output.split(RESULT_SEPARATOR_PATTERN).map(parseBlock).filter(Boolean)
119
- }
120
-
121
- export function outputLineRange(blocks) {
122
- const numbers = (blocks ?? []).flatMap((block) => (block.lines ?? []).flatMap((line) => line.line === undefined ? [] : [line.line]))
123
- if (numbers.length === 0) return undefined
124
- return { first: Math.min(...numbers), last: Math.max(...numbers) }
125
- }
126
-
127
- export function outputDomains(blocks) {
128
- const domains = []
129
- for (const block of blocks ?? []) {
130
- if (!block.url) continue
131
- try { pushUnique(domains, new URL(block.url).hostname) } catch { /* ignore malformed source URLs */ }
132
- }
133
- return domains
134
- }
135
-
136
- export function outputLinks(blocks) {
137
- const links = []
138
- for (const block of blocks ?? []) {
139
- for (const link of block.links ?? []) {
140
- if (!links.some((value) => value.id === link.id)) links.push({ ...link })
141
- }
142
- }
143
- return links
144
- }
145
-
146
- export function outputPdfRefs(blocks) {
147
- const refs = []
148
- for (const block of blocks ?? []) {
149
- if (!(block.metadata ?? []).includes('PDF')) continue
150
- for (const ref of block.references ?? []) pushUnique(refs, ref)
151
- }
152
- return refs
153
- }
154
-
155
- export function blockPlainText(block) {
156
- return (block?.lines ?? []).map((line) => line.text).filter(Boolean).join(' ')
157
- }
6
+ function pushUnique(target, value) { if (!target.includes(value)) target.push(value) }
7
+ function pushLink(target, payload) { const [idValue, labelValue, domainValue] = payload.split('†'); if (!/^\d+$/u.test(idValue ?? '') || !labelValue) return false; const id = Number(idValue); if (!Number.isSafeInteger(id)) return false; const label = labelValue.trim().slice(0, 500); const domain = domainValue?.trim().slice(0, 253); if (!label) return false; const existing = target.find((link) => link.id === id); if (!existing) target.push({ id, label, ...(domain ? { domain } : {}) }); else if (!existing.domain && domain) existing.domain = domain; return true }
8
+ function cleanCitations(value, references, links) { return String(value ?? '').replace(CITATION_PATTERN, (_match, payload) => { if (/^turn[\w-]+$/u.test(payload)) { pushUnique(references, payload); return '' } if (pushLink(links, payload)) return payload.split('†')[1]; const separator = payload.indexOf('†'); return separator < 0 ? payload : payload.slice(separator + 1) }).trim() }
9
+ function metadataParts(value) { return value.replace(/^\[wordlim:\s*(\d+)\]\s*/u, '$1-word excerpt; ').split(/;\s*/u).map((p) => p.trim()).filter(Boolean).map((part) => { const contentType = part.match(/^Content type:\s*(.+)$/u); if (contentType) return contentType[1] === 'text/html' ? 'HTML' : contentType[1] === 'application/pdf' ? 'PDF' : contentType[1]; const totalLines = part.match(/^Total lines:\s*(\d+)$/u); if (totalLines) return `${totalLines[1]} lines`; const pages = part.match(/^Number of pages:\s*(\d+)$/u); if (pages) return `${pages[1]} pages`; return part }) }
10
+ function isMetadata(value) { return value.startsWith('[wordlim:') || /^(?:Published|Crawled|Content type|Source|Total lines|Number of pages):/u.test(value) }
11
+ function parseLine(value, references, links) { const clean = cleanCitations(value, references, links); const pageLine = clean.match(PAGE_LINE_PATTERN); if (pageLine) { const text = pageLine[4] ?? ''; const heading = text.match(/^(#{1,6})\s+(.+)$/u); return { line: Number(pageLine[1]), ...(pageLine[2] === undefined ? {} : { page: Number(pageLine[2]) }), ...(pageLine[3] === undefined ? {} : { pageEnd: Number(pageLine[3]) }), text: heading?.[2] ?? text, ...(heading ? { heading: heading[1].length } : {}) } } const heading = clean.match(/^(#{1,6})\s+(.+)$/u); return heading ? { text: heading[2], heading: heading[1].length } : { text: clean } }
12
+ function parseBlock(value) { const references = [], links = []; const rawLines = String(value ?? '').replace(EMBEDDED_PAGE_LINE_PATTERN, '\n').split(/\r?\n/u).map((line) => line.trimEnd()); while (rawLines[0]?.trim() === '') rawLines.shift(); while (rawLines.at(-1)?.trim() === '') rawLines.pop(); if (!rawLines.length) return undefined; const firstLine = cleanCitations(rawLines[0], references, links); const header = firstLine.match(TITLE_URL_PATTERN); const titleOnly = firstLine.match(/^(.+?)\s*\(\)\s*$/u); let title = header?.[1]?.trim() || titleOnly?.[1]?.trim() || undefined; const url = header?.[2]; let bodyStart = header || titleOnly ? 1 : 0; if (!header && /^\s*\([^)]*\)\s*$/u.test(firstLine)) bodyStart = 1; const metadata = []; while (bodyStart < rawLines.length) { const clean = cleanCitations(rawLines[bodyStart], references, links); if (!isMetadata(clean)) break; metadata.push(...metadataParts(clean)); bodyStart += 1 } const lines = rawLines.slice(bodyStart).map((line) => parseLine(line, references, links)); if (!title && url) { try { title = new URL(url).hostname } catch { title = url } } return { ...(title ? { title } : {}), ...(url ? { url } : {}), references, links, metadata, lines } }
13
+ export function parseWebRunOutput(output) { return typeof output === 'string' && output ? output.split(RESULT_SEPARATOR_PATTERN).map(parseBlock).filter(Boolean) : [] }
14
+ export function outputLineRange(blocks) { const numbers = (blocks ?? []).flatMap((block) => (block.lines ?? []).flatMap((line) => line.line === undefined ? [] : [line.line])); return numbers.length ? { first: Math.min(...numbers), last: Math.max(...numbers) } : undefined }
15
+ export function outputDomains(blocks) { const domains = []; for (const block of blocks ?? []) if (block.url) { try { pushUnique(domains, new URL(block.url).hostname) } catch {} } return domains }
16
+ export function outputLinks(blocks) { const links = []; for (const block of blocks ?? []) for (const link of block.links ?? []) if (!links.some((v) => v.id === link.id)) links.push({ ...link }); return links }
17
+ export function outputPdfRefs(blocks) { const refs = []; for (const block of blocks ?? []) { if (!(block.metadata ?? []).includes('PDF')) continue; for (const ref of block.references ?? []) pushUnique(refs, ref) } return refs }
18
+ export function blockPlainText(block) { return (block?.lines ?? []).map((line) => line.text).filter(Boolean).join(' ') }