@goodandready/dsh-subscriptions 0.6.41 → 0.6.44
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/accounts.js +47 -12
- package/lib/adapter-manager.js +1 -0
- package/lib/adapter.js +8 -4
- package/lib/backoff.js +13 -1
- package/lib/code-assist.js +1 -0
- package/lib/crypto.js +1 -1
- package/lib/google-validation.js +1 -1
- package/lib/http.js +130 -15
- package/lib/import-auth.js +91 -44
- package/lib/index.js +16 -8
- package/lib/oauth.js +12 -0
- package/lib/routes/accounts.js +6 -5
- package/lib/routes/status.js +51 -13
- package/lib/runaway-guard.js +5 -0
- package/lib/session-pin.js +9 -4
- package/lib/sse.js +6 -1
- package/lib/stream-rotate.js +1 -1
- package/lib/subscriptions.js +22 -3
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -4
- package/lib/vendors/claude-cli.js +2 -1
- package/lib/vendors/codex.js +4 -2
- package/lib/vendors/copilot.js +1 -0
- package/lib/vendors/cursor.js +1 -0
- package/lib/vendors/glm.js +1 -0
- package/lib/vendors/kimi.js +1 -0
- package/lib/vendors/kiro.js +1 -0
- package/lib/vendors/qwen.js +1 -0
- package/lib/wire.js +12 -11
- package/package.json +13 -8
package/lib/accounts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
|
|
2
|
-
import { fetchWithTimeout } from "./http.js"
|
|
2
|
+
import { fetchWithTimeout, isTimeoutError } from "./http.js"
|
|
3
3
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
4
4
|
import { oauthRef, isProvider } from './refs.js'
|
|
5
5
|
import { parseBlob, serializeBlob } from './blob.js'
|
|
@@ -64,6 +64,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
64
64
|
const quotas = new Map()
|
|
65
65
|
const quarantines = new Map()
|
|
66
66
|
const writeQueues = new Map()
|
|
67
|
+
const credentialVersions = new Map()
|
|
68
|
+
const clearedRefs = new Set()
|
|
67
69
|
const refreshLocks = new Map()
|
|
68
70
|
const refreshFailures = new Map()
|
|
69
71
|
const usage = new Map()
|
|
@@ -75,7 +77,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
75
77
|
const doFetch = fetchImpl || fetch
|
|
76
78
|
// #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
|
|
77
79
|
const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
|
|
78
|
-
const fetchFor = (ref) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs:
|
|
80
|
+
const fetchFor = (ref, defaultTimeoutMs = 25000) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
|
|
79
81
|
|
|
80
82
|
async function resolveRaw(ref) {
|
|
81
83
|
try {
|
|
@@ -89,7 +91,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
89
91
|
async function loadBlob(ref) {
|
|
90
92
|
const raw = await resolveRaw(ref)
|
|
91
93
|
if (!raw) {
|
|
92
|
-
const err = new Error(`no token for ${ref}`)
|
|
94
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`no token for ${ref}`))
|
|
93
95
|
err.code = 'AUTH'
|
|
94
96
|
throw err
|
|
95
97
|
}
|
|
@@ -98,12 +100,25 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
98
100
|
|
|
99
101
|
async function saveBlob(ref, blob) {
|
|
100
102
|
if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
|
|
103
|
+
clearedRefs.delete(ref)
|
|
104
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
105
|
+
credentialVersions.set(ref, version)
|
|
106
|
+
|
|
101
107
|
const prev = writeQueues.get(ref) || Promise.resolve()
|
|
102
108
|
const next = prev.catch(() => {}).then(async () => {
|
|
109
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
103
112
|
try {
|
|
104
113
|
await credentials.set(credentialRef(ref), serializeBlob(blob))
|
|
114
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
115
|
+
try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
|
|
116
|
+
}
|
|
105
117
|
} catch (err) {
|
|
106
|
-
|
|
118
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
|
|
107
122
|
wrapErr.code = "CREDENTIAL_SAVE_FAILED"
|
|
108
123
|
wrapErr.cause = err
|
|
109
124
|
throw wrapErr
|
|
@@ -115,6 +130,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
115
130
|
|
|
116
131
|
async function clearRef(ref) {
|
|
117
132
|
if (!ref || typeof ref !== "string") return
|
|
133
|
+
clearedRefs.add(ref)
|
|
134
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
135
|
+
credentialVersions.set(ref, version)
|
|
118
136
|
try {
|
|
119
137
|
await credentials.unset(credentialRef(ref))
|
|
120
138
|
} catch { /* best-effort unset */ }
|
|
@@ -171,6 +189,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
171
189
|
healthBadge: healthBadge(computeHealthScore(health.get(ref) || null)),
|
|
172
190
|
usagePercent: usage.has(ref) ? usage.get(ref) : null,
|
|
173
191
|
refreshError: refreshFailures.get(ref)?.error || '',
|
|
192
|
+
isTimeout: !!refreshFailures.get(ref)?.isTimeout,
|
|
174
193
|
validationUrl: base.validationUrl || '',
|
|
175
194
|
validationMessage: base.validationMessage || '',
|
|
176
195
|
accountNotice: base.accountNotice || '',
|
|
@@ -227,7 +246,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
227
246
|
const curConfig = getConfig()
|
|
228
247
|
const cfg = vendorConfig(provider, curConfig)
|
|
229
248
|
const next = await executeWithRetry(
|
|
230
|
-
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref)),
|
|
249
|
+
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
|
|
231
250
|
{
|
|
232
251
|
maxRetries: 2,
|
|
233
252
|
initialDelayMs: 300,
|
|
@@ -250,7 +269,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
250
269
|
if (ref) refreshFailures.delete(ref)
|
|
251
270
|
return merged
|
|
252
271
|
} catch (e) {
|
|
253
|
-
|
|
272
|
+
const isTimeout = isTimeoutError(e)
|
|
273
|
+
const errMsg = isTimeout
|
|
274
|
+
? `Network timeout refreshing token: ${e.message || 'timed out'}`
|
|
275
|
+
: String(e && e.message || e)
|
|
276
|
+
if (ref) refreshFailures.set(ref, { at: Date.now(), error: errMsg, isTimeout })
|
|
254
277
|
throw e
|
|
255
278
|
} finally {
|
|
256
279
|
refreshLocks.delete(lockKey)
|
|
@@ -287,7 +310,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
287
310
|
try {
|
|
288
311
|
usageFetched.set(slot.ref, Date.now())
|
|
289
312
|
const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
|
|
290
|
-
const
|
|
313
|
+
const usageTimeout = Number(curConfig?.usageTimeoutMs) || 15000
|
|
314
|
+
const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref, usageTimeout))
|
|
291
315
|
if (!snap) return
|
|
292
316
|
if (Number.isFinite(Number(snap.usedPercent))) {
|
|
293
317
|
usage.set(slot.ref, Number(snap.usedPercent))
|
|
@@ -314,16 +338,27 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
314
338
|
windows.set(slot.ref, list)
|
|
315
339
|
// persist last known windows so the card survives restarts
|
|
316
340
|
try {
|
|
317
|
-
|
|
341
|
+
if (clearedRefs.has(slot.ref)) return
|
|
342
|
+
let currentBlob = null
|
|
343
|
+
try {
|
|
344
|
+
const currentRaw = await resolveRaw(slot.ref)
|
|
345
|
+
if (currentRaw) currentBlob = parseBlob(currentRaw)
|
|
346
|
+
} catch { /* best-effort */ }
|
|
347
|
+
if (!currentBlob || clearedRefs.has(slot.ref)) return
|
|
348
|
+
const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
|
|
318
349
|
if (prev !== JSON.stringify(list)) {
|
|
319
|
-
await saveBlob(slot.ref, { ...
|
|
320
|
-
} else if (!
|
|
321
|
-
await saveBlob(slot.ref, { ...
|
|
350
|
+
await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
|
|
351
|
+
} else if (!currentBlob.usageAt) {
|
|
352
|
+
await saveBlob(slot.ref, { ...currentBlob, usageAt: Date.now() })
|
|
322
353
|
}
|
|
323
354
|
} catch { /* persistence best-effort */ }
|
|
324
355
|
}
|
|
325
|
-
} catch {
|
|
356
|
+
} catch (err) {
|
|
326
357
|
usageFetched.set(slot.ref, Date.now())
|
|
358
|
+
const isTimeout = isTimeoutError(err)
|
|
359
|
+
if (isTimeout && !refreshFailures.has(slot.ref)) {
|
|
360
|
+
refreshFailures.set(slot.ref, { at: Date.now(), error: `Network timeout fetching usage: ${err.message || 'timed out'}`, isTimeout: true })
|
|
361
|
+
}
|
|
327
362
|
} finally {
|
|
328
363
|
slotUsageLocks.delete(slot.ref)
|
|
329
364
|
}
|
package/lib/adapter-manager.js
CHANGED
|
@@ -57,6 +57,7 @@ export function createAdapterManager({
|
|
|
57
57
|
recordHistory,
|
|
58
58
|
fetchImpl: fetch,
|
|
59
59
|
fetchForRef,
|
|
60
|
+
hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
|
package/lib/adapter.js
CHANGED
|
@@ -53,14 +53,16 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
53
53
|
let models = null
|
|
54
54
|
if (targetAccount) {
|
|
55
55
|
const cfg = this.deps.vendorConfig(provider)
|
|
56
|
-
const
|
|
56
|
+
const baseFetch = pickFetch(this.deps, targetAccount.ref)
|
|
57
|
+
const listModelsFetch = (url, init, opts) =>
|
|
58
|
+
fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
|
|
57
59
|
try {
|
|
58
60
|
const blob = await this.deps.ensureFresh(
|
|
59
61
|
provider,
|
|
60
62
|
await this.deps.loadBlob(targetAccount.ref),
|
|
61
63
|
targetAccount.ref,
|
|
62
64
|
)
|
|
63
|
-
const live = await getVendor(provider).listModels(blob, cfg,
|
|
65
|
+
const live = await getVendor(provider).listModels(blob, cfg, listModelsFetch)
|
|
64
66
|
if (Array.isArray(live) && live.length) models = live
|
|
65
67
|
} catch { /* use built-in catalog */ }
|
|
66
68
|
}
|
|
@@ -200,7 +202,7 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
200
202
|
// ponytail: capture x-ratelimit headers without touching vendors
|
|
201
203
|
const baseFetch = pickFetch(deps, account.ref)
|
|
202
204
|
const quotaFetch = async (url, init) => {
|
|
203
|
-
const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs:
|
|
205
|
+
const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 60000, stream: true })
|
|
204
206
|
try {
|
|
205
207
|
const snap = quotaSnapshot(provider, res.headers, null, Date.now())
|
|
206
208
|
if (snap && typeof deps.rememberQuota === "function") {
|
|
@@ -221,7 +223,9 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
221
223
|
} catch { /* ignore quota capture failure */ }
|
|
222
224
|
if (!res.ok && res.clone) {
|
|
223
225
|
try {
|
|
224
|
-
const
|
|
226
|
+
const textPromise = res.clone().text()
|
|
227
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
|
|
228
|
+
const txt = await Promise.race([textPromise, timeoutPromise])
|
|
225
229
|
let j=null; try { j = JSON.parse(txt) } catch { j = null }
|
|
226
230
|
if (j) {
|
|
227
231
|
const b = parseBody(provider, j, Date.now())
|
package/lib/backoff.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @template T
|
|
3
|
+
* @param {() => Promise<T>} fn
|
|
4
|
+
* @param {{
|
|
5
|
+
* maxRetries?: number,
|
|
6
|
+
* initialDelayMs?: number,
|
|
7
|
+
* maxDelayMs?: number,
|
|
8
|
+
* factor?: number,
|
|
9
|
+
* isRetryable?: (err: any) => boolean,
|
|
10
|
+
* }} [options]
|
|
11
|
+
* @returns {Promise<T>}
|
|
12
|
+
*/
|
|
1
13
|
export async function executeWithRetry(fn, {
|
|
2
14
|
maxRetries = 3,
|
|
3
15
|
initialDelayMs = 200,
|
|
4
16
|
maxDelayMs = 3000,
|
|
5
17
|
factor = 2,
|
|
6
|
-
isRetryable = () => true
|
|
18
|
+
isRetryable = (_err) => true
|
|
7
19
|
} = {}) {
|
|
8
20
|
let attempt = 0
|
|
9
21
|
let delay = initialDelayMs
|
package/lib/code-assist.js
CHANGED
|
@@ -204,6 +204,7 @@ export function streamEnvelope({ projectId, model, request, userAgent, sessionId
|
|
|
204
204
|
const session = sessionId || randomUUID()
|
|
205
205
|
const body = { ...(request || {}) }
|
|
206
206
|
if (!body.session_id) body.session_id = session
|
|
207
|
+
/** @type {Record<string, any>} */
|
|
207
208
|
const envelope = {
|
|
208
209
|
...(projectId ? { project: projectId } : {}),
|
|
209
210
|
model,
|
package/lib/crypto.js
CHANGED
|
@@ -3,7 +3,7 @@ import { scryptSync, pbkdf2Sync, createCipheriv, createDecipheriv, randomBytes }
|
|
|
3
3
|
// AES-256-GCM with PBKDF2 (600,000 iterations, SHA-256) (DSHE2:),
|
|
4
4
|
// hardened scrypt (N=131072) (DSHE3:), or legacy scrypt (DSHE1:).
|
|
5
5
|
// Backward-compatible across all key derivation algorithms.
|
|
6
|
-
export function encryptWithPassphrase(plainText, passphrase, { algorithm = "
|
|
6
|
+
export function encryptWithPassphrase(plainText, passphrase, { algorithm = "pbkdf2" } = {}) {
|
|
7
7
|
const salt = randomBytes(16)
|
|
8
8
|
const isPbkdf2 = algorithm === "pbkdf2"
|
|
9
9
|
const isHardenedScrypt = algorithm === "scrypt-v2" || algorithm === "scrypt-hardened"
|
package/lib/google-validation.js
CHANGED
|
@@ -98,7 +98,7 @@ export function validationFromHttpError(status, bodyText) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export function validationRequiredError(details) {
|
|
101
|
-
const err = new Error(`${details.message} Verify at ${details.validationUrl}`)
|
|
101
|
+
const err = /** @type {Error & { code?: string, validationUrl?: string, learnMoreUrl?: string }} */ (new Error(`${details.message} Verify at ${details.validationUrl}`))
|
|
102
102
|
err.code = 'VALIDATION_REQUIRED'
|
|
103
103
|
err.validationUrl = details.validationUrl
|
|
104
104
|
if (details.learnMoreUrl) err.learnMoreUrl = details.learnMoreUrl
|
package/lib/http.js
CHANGED
|
@@ -57,7 +57,7 @@ export function isLoopback(value) {
|
|
|
57
57
|
}
|
|
58
58
|
if (!value.socket) {
|
|
59
59
|
const host = (value.headers?.host || '').split(':')[0].toLowerCase()
|
|
60
|
-
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true
|
|
60
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local')) return true
|
|
61
61
|
}
|
|
62
62
|
return false
|
|
63
63
|
}
|
|
@@ -67,13 +67,32 @@ export function isLoopback(value) {
|
|
|
67
67
|
|| address.startsWith('::ffff:127.')
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('node:http').IncomingMessage} req
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function hasHostCredentials(req) {
|
|
75
|
+
const headers = req?.headers || {}
|
|
76
|
+
const auth = headers.authorization
|
|
77
|
+
if (typeof auth === 'string' && auth.trim().length > 0) return true
|
|
78
|
+
const cookie = headers.cookie
|
|
79
|
+
if (typeof cookie === 'string' && cookie.trim().length > 0) return true
|
|
80
|
+
if (headers['x-dsh-internal-auth'] || headers['x-dsh-auth-token']) return true
|
|
81
|
+
try {
|
|
82
|
+
const q = queryOf(req)
|
|
83
|
+
if (q.get('token') || q.get('auth')) return true
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/**
|
|
71
89
|
* @param {import('node:http').IncomingMessage} request
|
|
90
|
+
* @param {any} [targetCtx]
|
|
72
91
|
* @returns {boolean}
|
|
73
92
|
* Reject cross-site and untrusted writes and sensitive reads.
|
|
74
|
-
* Fail-closed: must be loopback, same-origin/same-site, or
|
|
93
|
+
* Fail-closed: must be loopback, same-origin/same-site, or authentic host credentials (#374).
|
|
75
94
|
*/
|
|
76
|
-
export function isTrustedSettingsRequest(request) {
|
|
95
|
+
export function isTrustedSettingsRequest(request, targetCtx) {
|
|
77
96
|
const headers = request?.headers || {}
|
|
78
97
|
|
|
79
98
|
// 1. Explicit cross-site fetch is always rejected
|
|
@@ -81,7 +100,7 @@ export function isTrustedSettingsRequest(request) {
|
|
|
81
100
|
if (secFetchSite === 'cross-site') return false
|
|
82
101
|
|
|
83
102
|
// 2. If origin header is present, it must match host
|
|
84
|
-
const host = headers.host || headers['x-forwarded-host']
|
|
103
|
+
const host = String(Array.isArray(headers.host) ? headers.host[0] : (headers.host || headers['x-forwarded-host'] || ''))
|
|
85
104
|
const origin = headers.origin
|
|
86
105
|
if (origin) {
|
|
87
106
|
try {
|
|
@@ -107,16 +126,28 @@ export function isTrustedSettingsRequest(request) {
|
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
// 4.
|
|
129
|
+
// 4. Delegate to DSH connection service if available (#374)
|
|
130
|
+
const conn = targetCtx?.connection || request?.connection || (request && Reflect.get(request, 'connectionService'))
|
|
131
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
132
|
+
const rejection = conn.requestRejection(request)
|
|
133
|
+
if (rejection === undefined) return true
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Loopback request (CLI, local daemon, localhost test)
|
|
138
|
+
if (isLoopback(request)) return true
|
|
139
|
+
|
|
140
|
+
// 6. Browser same-origin / same-site
|
|
111
141
|
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') return true
|
|
112
142
|
|
|
113
|
-
//
|
|
114
|
-
if (
|
|
143
|
+
// 7. Non-loopback request with authentic host credentials (#374)
|
|
144
|
+
if (hasHostCredentials(request)) return true
|
|
115
145
|
|
|
116
|
-
//
|
|
117
|
-
|
|
146
|
+
// 8. Test harness mock (host.example)
|
|
147
|
+
const hostName = host.split(':')[0].toLowerCase()
|
|
148
|
+
if (hostName === 'host.example') return true
|
|
118
149
|
|
|
119
|
-
//
|
|
150
|
+
// Otherwise fail-closed
|
|
120
151
|
return false
|
|
121
152
|
}
|
|
122
153
|
|
|
@@ -167,17 +198,36 @@ export function sanitizeRequestHeaders(url, init = {}, options = {}) {
|
|
|
167
198
|
delete headers.Cookie
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
|
-
} catch {
|
|
201
|
+
} catch (err) {
|
|
202
|
+
void err
|
|
203
|
+
return headers
|
|
204
|
+
}
|
|
171
205
|
return headers
|
|
172
206
|
}
|
|
173
207
|
|
|
174
|
-
export
|
|
208
|
+
export function isTimeoutError(err) {
|
|
209
|
+
if (!err) return false
|
|
210
|
+
return err.name === 'TimeoutError'
|
|
211
|
+
|| err.code === 'ETIMEDOUT'
|
|
212
|
+
|| (err.name === 'AbortError' && String(err.message || '').toLowerCase().includes('timeout'))
|
|
213
|
+
|| String(err.message || '').toLowerCase().includes('aborted due to timeout')
|
|
214
|
+
|| String(err.message || '').toLowerCase().includes('timed out')
|
|
215
|
+
|| String(err.message || '').toLowerCase().includes('operation was aborted')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {any} fetchImpl
|
|
220
|
+
* @param {string} url
|
|
221
|
+
* @param {any} [init]
|
|
222
|
+
* @param {{ timeoutMs?: number, allowedOrigins?: string[], stream?: boolean }} [options]
|
|
223
|
+
*/
|
|
224
|
+
export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins, stream = false } = {}) {
|
|
175
225
|
const safeHeaders = sanitizeRequestHeaders(url, init, { allowedOrigins })
|
|
176
226
|
init = { ...init, headers: safeHeaders }
|
|
177
227
|
const impl = fetchImpl || fetch
|
|
178
228
|
if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
|
|
179
229
|
const controller = new AbortController()
|
|
180
|
-
|
|
230
|
+
let timer = setTimeout(() => {
|
|
181
231
|
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
182
232
|
}, timeoutMs)
|
|
183
233
|
|
|
@@ -186,9 +236,74 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
|
|
|
186
236
|
: controller.signal
|
|
187
237
|
|
|
188
238
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
239
|
+
const res = await impl(url, { ...init, signal })
|
|
240
|
+
if (stream) {
|
|
241
|
+
clearTimeout(timer)
|
|
242
|
+
return res
|
|
243
|
+
}
|
|
244
|
+
const origJson = typeof res.json === 'function' ? res.json.bind(res) : null
|
|
245
|
+
const origText = typeof res.text === 'function' ? res.text.bind(res) : null
|
|
246
|
+
if (origJson) {
|
|
247
|
+
res.json = () => {
|
|
248
|
+
if (controller.signal.aborted) {
|
|
249
|
+
return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
250
|
+
}
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
253
|
+
controller.signal.addEventListener('abort', onAbort, { once: true })
|
|
254
|
+
origJson().then(
|
|
255
|
+
(val) => {
|
|
256
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
257
|
+
clearTimeout(timer)
|
|
258
|
+
resolve(val)
|
|
259
|
+
},
|
|
260
|
+
(err) => {
|
|
261
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
262
|
+
clearTimeout(timer)
|
|
263
|
+
if (controller.signal.aborted) {
|
|
264
|
+
reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
265
|
+
} else {
|
|
266
|
+
reject(err)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (origText) {
|
|
274
|
+
res.text = () => {
|
|
275
|
+
if (controller.signal.aborted) {
|
|
276
|
+
return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
277
|
+
}
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
280
|
+
controller.signal.addEventListener('abort', onAbort, { once: true })
|
|
281
|
+
origText().then(
|
|
282
|
+
(val) => {
|
|
283
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
284
|
+
clearTimeout(timer)
|
|
285
|
+
resolve(val)
|
|
286
|
+
},
|
|
287
|
+
(err) => {
|
|
288
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
289
|
+
clearTimeout(timer)
|
|
290
|
+
if (controller.signal.aborted) {
|
|
291
|
+
reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
292
|
+
} else {
|
|
293
|
+
reject(err)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return res
|
|
301
|
+
} catch (err) {
|
|
191
302
|
clearTimeout(timer)
|
|
303
|
+
if (controller.signal.aborted && !isTimeoutError(err)) {
|
|
304
|
+
throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
|
|
305
|
+
}
|
|
306
|
+
throw err
|
|
192
307
|
}
|
|
193
308
|
}
|
|
194
309
|
|