@ljwei-stak/dsh-model-router 0.16.0 → 0.16.2
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/.dsh-plugin/client.js +314 -80
- package/.dsh-plugin/index.mjs +23 -17
- package/.dsh-plugin/shared/adaptive-feedback.mjs +120 -40
- package/.dsh-plugin/shared/dynamic-data-view.mjs +59 -4
- package/.dsh-plugin/shared/dynamic-data.mjs +161 -83
- package/.dsh-plugin/shared/latest-versions.mjs +3 -21
- package/.dsh-plugin/shared/official-tools-runtime.mjs +188 -41
- package/.dsh-plugin/shared/router-state.mjs +139 -38
- package/.dsh-plugin/shared/run-ledger.mjs +32 -11
- package/.dsh-plugin/shared/version-order.mjs +30 -0
- package/CHANGELOG.md +20 -0
- package/INSTALLATION_GUIDE.zh.md +12 -12
- package/README.md +9 -5
- package/README.zh.md +9 -5
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +2 -0
- package/docs/ROUTING_MATURITY.zh.md +76 -0
- package/package.json +3 -1
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
* their own outbound network policy if untrusted users can configure endpoints.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash, randomUUID } from 'node:crypto'
|
|
12
|
-
import { isIP } from 'node:net'
|
|
13
12
|
import { fetchLiveBenchSnapshot } from './livebench.mjs'
|
|
14
|
-
|
|
13
|
+
import { projectDynamicData, publicDynamicEndpoint } from './dynamic-data-view.mjs'
|
|
14
|
+
export { applyPricingSnapshot, dynamicDataSummary, routingData, projectDynamicData, publicDynamicEndpoint } from './dynamic-data-view.mjs'
|
|
15
15
|
|
|
16
16
|
const DEFAULT_TTL_MS = 86_400_000
|
|
17
17
|
const TIMEOUT_MS = 8000
|
|
18
18
|
const MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
|
|
19
19
|
const MAX_ROWS = 2000
|
|
20
20
|
const MAX_CLOCK_SKEW_MS = 60_000
|
|
21
|
+
const RESERVATION_GRACE_MS = 5000
|
|
21
22
|
const ROW_FIELDS = new Set(['provider', 'model', 'input', 'output', 'cacheRead', 'cacheWrite', 'currency', 'unit', 'asOf', 'sourceUrl'])
|
|
22
23
|
const PRICE_FIELDS = new Set(['schemaVersion', 'kind', 'version', 'publishedAt', 'rows'])
|
|
23
24
|
const SCORE_FIELDS = new Set(['reasoning', 'code', 'math', 'research', 'writing', 'vision', 'summarization', 'classification'])
|
|
@@ -43,16 +44,7 @@ function revisionOf(data) {
|
|
|
43
44
|
return hash({ liveBench: data.liveBench?.hash ?? null, pricing: data.pricing?.hash ?? null, status: data.status })
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
if (typeof value !== 'string' || value.length > 2048) throw new Error('invalid-public-url')
|
|
48
|
-
const url = new URL(value)
|
|
49
|
-
const host = url.hostname.toLowerCase()
|
|
50
|
-
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|
|
51
|
-
|| (url.port && url.port !== '443') || isIP(host) || host.startsWith('[')
|
|
52
|
-
|| !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/u.test(host)
|
|
53
|
-
|| /(?:^|\.)(?:localhost|local|internal|lan|home|test|invalid)$/u.test(host)) throw new Error('invalid-public-url')
|
|
54
|
-
return url.toString()
|
|
55
|
-
}
|
|
47
|
+
const publicUrl = publicDynamicEndpoint
|
|
56
48
|
|
|
57
49
|
function identifier(value, max = 240) {
|
|
58
50
|
if (typeof value !== 'string' || value.trim() !== value || !value || value.length > max
|
|
@@ -148,59 +140,94 @@ function mayPromote(previous, candidate) {
|
|
|
148
140
|
return true
|
|
149
141
|
}
|
|
150
142
|
|
|
151
|
-
|
|
143
|
+
function cancelBody(body) {
|
|
144
|
+
try { Promise.resolve(body?.cancel?.()).catch(() => {}) } catch { /* best effort; never block rejection */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function limitedText(response, signal) {
|
|
152
148
|
const length = Number(response.headers?.get?.('content-length'))
|
|
153
|
-
if (Number.isFinite(length) && length > MAX_PAYLOAD_BYTES)
|
|
149
|
+
if (Number.isFinite(length) && length > MAX_PAYLOAD_BYTES) {
|
|
150
|
+
cancelBody(response.body)
|
|
151
|
+
throw new Error('payload-too-large')
|
|
152
|
+
}
|
|
154
153
|
if (!response.body?.getReader) {
|
|
155
154
|
const text = await response.text()
|
|
156
155
|
if (Buffer.byteLength(text, 'utf8') > MAX_PAYLOAD_BYTES) throw new Error('payload-too-large')
|
|
157
156
|
return text
|
|
158
157
|
}
|
|
159
158
|
const reader = response.body.getReader()
|
|
160
|
-
const decoder = new TextDecoder()
|
|
159
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
161
160
|
let size = 0
|
|
162
161
|
let text = ''
|
|
162
|
+
const cancelReader = () => { try { Promise.resolve(reader.cancel()).catch(() => {}) } catch {} }
|
|
163
|
+
signal?.addEventListener('abort', cancelReader, { once: true })
|
|
163
164
|
try {
|
|
164
165
|
for (;;) {
|
|
166
|
+
if (signal?.aborted) throw new Error('refresh-aborted')
|
|
165
167
|
const chunk = await reader.read()
|
|
168
|
+
if (signal?.aborted) throw new Error('refresh-aborted')
|
|
166
169
|
if (chunk.done) return text + decoder.decode()
|
|
167
170
|
size += chunk.value.byteLength
|
|
168
171
|
if (size > MAX_PAYLOAD_BYTES) throw new Error('payload-too-large')
|
|
169
172
|
text += decoder.decode(chunk.value, { stream: true })
|
|
170
173
|
}
|
|
171
174
|
} catch (error) {
|
|
172
|
-
|
|
175
|
+
cancelReader()
|
|
173
176
|
throw error
|
|
174
|
-
} finally { reader.releaseLock() }
|
|
177
|
+
} finally { signal?.removeEventListener('abort', cancelReader); reader.releaseLock() }
|
|
175
178
|
}
|
|
176
179
|
|
|
177
|
-
function publicFetch(fetchImpl) {
|
|
180
|
+
function publicFetch(fetchImpl, outerSignal) {
|
|
178
181
|
return async (input, options = {}) => {
|
|
179
182
|
const endpoint = publicUrl(String(input))
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
const controller = new AbortController()
|
|
184
|
+
const signals = [...new Set([outerSignal, options.signal].filter(Boolean))]
|
|
185
|
+
const abort = () => controller.abort(new Error('refresh-aborted'))
|
|
186
|
+
const cleanup = () => { for (const signal of signals) signal.removeEventListener('abort', abort) }
|
|
187
|
+
for (const signal of signals) {
|
|
188
|
+
if (signal.aborted) abort()
|
|
189
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
190
|
+
}
|
|
191
|
+
controller.signal.addEventListener('abort', cleanup, { once: true })
|
|
192
|
+
let response
|
|
193
|
+
try {
|
|
194
|
+
if (controller.signal.aborted) throw new Error('refresh-aborted')
|
|
195
|
+
response = await fetchImpl(endpoint, {
|
|
196
|
+
method: 'GET', headers: { accept: 'application/json,text/csv,text/html' },
|
|
197
|
+
credentials: 'omit', redirect: 'error', signal: controller.signal,
|
|
198
|
+
})
|
|
199
|
+
if (controller.signal.aborted || !response?.ok || response.redirected) {
|
|
200
|
+
throw new Error('public-fetch-failed')
|
|
201
|
+
}
|
|
202
|
+
if (response.url && publicUrl(response.url) !== endpoint) {
|
|
203
|
+
throw new Error('public-fetch-redirected')
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, status: response.status, headers: response.headers,
|
|
206
|
+
text: async () => { try { return await limitedText(response, controller.signal) } finally { cleanup() } } }
|
|
207
|
+
} catch (error) { cancelBody(response?.body); cleanup(); throw error }
|
|
188
208
|
}
|
|
189
209
|
}
|
|
190
210
|
|
|
191
|
-
async function withTimeout(run) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
timer = setTimeout(() =>
|
|
197
|
-
try {
|
|
198
|
-
|
|
211
|
+
async function withTimeout(run, controller, timeoutMs) {
|
|
212
|
+
let rejectAborted
|
|
213
|
+
const aborted = new Promise((_, reject) => { rejectAborted = reject })
|
|
214
|
+
const onAbort = () => rejectAborted(new Error('refresh-aborted'))
|
|
215
|
+
controller.signal.addEventListener('abort', onAbort, { once: true })
|
|
216
|
+
const timer = setTimeout(() => controller.abort(new Error('refresh-timeout')), timeoutMs)
|
|
217
|
+
try {
|
|
218
|
+
if (controller.signal.aborted) throw new Error('refresh-aborted')
|
|
219
|
+
return await Promise.race([run(controller.signal), aborted])
|
|
220
|
+
} finally { clearTimeout(timer); controller.signal.removeEventListener('abort', onAbort) }
|
|
199
221
|
}
|
|
200
222
|
|
|
201
223
|
function due(data, kind, endpoint, ttlMs, at, force) {
|
|
202
224
|
const status = data.status?.[kind]
|
|
203
225
|
if (force || status?.endpoint !== endpoint || !finite(status?.lastAttemptAt)) return true
|
|
226
|
+
if (at < status.lastAttemptAt) return true // a corrected clock must not freeze refresh forever
|
|
227
|
+
if (status.requestId) {
|
|
228
|
+
const deadline = finite(status.requestDeadlineAt) ? status.requestDeadlineAt : status.lastAttemptAt + TIMEOUT_MS + RESERVATION_GRACE_MS
|
|
229
|
+
return at >= deadline
|
|
230
|
+
}
|
|
204
231
|
const delay = status.error ? Math.min(ttlMs, 300_000) : ttlMs
|
|
205
232
|
return at - status.lastAttemptAt >= delay
|
|
206
233
|
}
|
|
@@ -210,11 +237,32 @@ function due(data, kind, endpoint, ttlMs, at, force) {
|
|
|
210
237
|
* mutates the fresh state under the caller's interprocess/atomic write lock.
|
|
211
238
|
*/
|
|
212
239
|
export function createDynamicDataRefresher({ store, fetchImpl = globalThis.fetch, now = Date.now,
|
|
213
|
-
fetchBenchmark = fetchLiveBenchSnapshot } = {}) {
|
|
240
|
+
fetchBenchmark = fetchLiveBenchSnapshot, timeoutMs = TIMEOUT_MS } = {}) {
|
|
214
241
|
if (typeof store?.read !== 'function' || typeof store?.update !== 'function') throw new Error('dynamic-data-store-required')
|
|
242
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS) throw new Error('invalid-refresh-timeout')
|
|
215
243
|
const flights = new Map()
|
|
244
|
+
const active = new Map()
|
|
245
|
+
const sourceStates = new Map()
|
|
246
|
+
const generationOf = kind => sourceStates.get(kind)?.generation ?? 0
|
|
216
247
|
const refresh = async (options = {}) => {
|
|
217
|
-
if (options.enabled !== true)
|
|
248
|
+
if (options.enabled !== true) {
|
|
249
|
+
for (const kind of ['liveBench', 'pricing']) sourceStates.set(kind, { fingerprint: '', generation: generationOf(kind) + 1 })
|
|
250
|
+
const pending = [...active.values()]
|
|
251
|
+
for (const request of pending) request.controller.abort()
|
|
252
|
+
if (pending.length) {
|
|
253
|
+
try { await store.update(state => {
|
|
254
|
+
const data = dataOf(state)
|
|
255
|
+
for (const request of pending) if (data.status[request.kind]?.requestId === request.requestId) {
|
|
256
|
+
data.status[request.kind] = { ...data.status[request.kind], disabled: true, endpoint: '', error: '' }
|
|
257
|
+
delete data.status[request.kind].requestId
|
|
258
|
+
delete data.status[request.kind].requestDeadlineAt
|
|
259
|
+
}
|
|
260
|
+
data.revision = revisionOf(data)
|
|
261
|
+
state.dynamicData = data
|
|
262
|
+
}) } catch { /* disabled projection is safe even if cancellation cannot be persisted */ }
|
|
263
|
+
}
|
|
264
|
+
return emptyData()
|
|
265
|
+
}
|
|
218
266
|
const ttlMs = finite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : DEFAULT_TTL_MS
|
|
219
267
|
const liveBenchEndpoint = options.liveBenchEndpoint ?? 'https://livebench.ai'
|
|
220
268
|
const pricingSnapshotEndpoint = options.pricingSnapshotEndpoint ?? ''
|
|
@@ -225,66 +273,96 @@ export function createDynamicDataRefresher({ store, fetchImpl = globalThis.fetch
|
|
|
225
273
|
try { return { kind, endpoint: publicUrl(raw), disabled: false, invalid: false } }
|
|
226
274
|
catch { return { kind, endpoint: '', disabled: false, invalid: true } }
|
|
227
275
|
})
|
|
228
|
-
const
|
|
276
|
+
for (const source of sources) {
|
|
277
|
+
const fingerprint = stable(source)
|
|
278
|
+
if (fingerprint !== sourceStates.get(source.kind)?.fingerprint) {
|
|
279
|
+
sourceStates.set(source.kind, { fingerprint, generation: generationOf(source.kind) + 1 })
|
|
280
|
+
for (const request of active.values()) if (request.kind === source.kind) request.controller.abort()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const generations = Object.fromEntries(sources.map(source => [source.kind, generationOf(source.kind)]))
|
|
284
|
+
const key = stable([sources, generations, ttlMs, options.force === true])
|
|
229
285
|
if (flights.has(key)) return flights.get(key)
|
|
230
286
|
const operation = (async () => {
|
|
231
287
|
const initial = dataOf(await store.read())
|
|
288
|
+
const storageFailures = new Set()
|
|
289
|
+
await Promise.all(sources.filter(source => source.disabled && (!initial.status[source.kind]?.disabled || initial.status[source.kind]?.requestId))
|
|
290
|
+
.map(async ({ kind }) => {
|
|
291
|
+
try { await store.update(state => {
|
|
292
|
+
if (generationOf(kind) !== generations[kind]) return
|
|
293
|
+
const data = dataOf(state)
|
|
294
|
+
data.status[kind] = { ...(data.status[kind] ?? {}), disabled: true, endpoint: '', error: '' }
|
|
295
|
+
delete data.status[kind].requestId
|
|
296
|
+
delete data.status[kind].requestDeadlineAt
|
|
297
|
+
data.revision = revisionOf(data)
|
|
298
|
+
state.dynamicData = data
|
|
299
|
+
}) } catch { storageFailures.add(kind) }
|
|
300
|
+
}))
|
|
232
301
|
const requests = sources.filter(source => !source.disabled)
|
|
233
302
|
.filter(({ kind, endpoint }) => due(initial, kind, endpoint, ttlMs, now(), options.force === true))
|
|
234
|
-
await Promise.
|
|
235
|
-
const at = now()
|
|
303
|
+
const settled = await Promise.allSettled(requests.map(async ({ kind, endpoint, invalid }) => {
|
|
236
304
|
const requestId = randomUUID()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const data = dataOf(state)
|
|
241
|
-
if (!due(data, kind, endpoint, ttlMs, at, options.force === true)) return false
|
|
242
|
-
data.status[kind] = { ...(data.status[kind] ?? {}), endpoint,
|
|
243
|
-
lastAttemptAt: at, requestId, error: '' }
|
|
244
|
-
data.revision = revisionOf(data)
|
|
245
|
-
state.dynamicData = data
|
|
246
|
-
return true
|
|
247
|
-
})
|
|
248
|
-
if (!reserved) return
|
|
249
|
-
let candidate
|
|
250
|
-
let error = ''
|
|
305
|
+
const controller = new AbortController()
|
|
306
|
+
const request = { kind, endpoint, requestId, controller }
|
|
307
|
+
active.set(requestId, request)
|
|
251
308
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
// Reserve before network I/O. Another process or source change wins by
|
|
310
|
+
// replacing this token; a late completion then cannot mutate its data.
|
|
311
|
+
const reserved = await store.update(state => {
|
|
312
|
+
if (generationOf(kind) !== generations[kind] || controller.signal.aborted) return false
|
|
313
|
+
const data = dataOf(state)
|
|
314
|
+
const at = now()
|
|
315
|
+
if (!due(data, kind, endpoint, ttlMs, at, options.force === true)) return false
|
|
316
|
+
data.status[kind] = { ...(data.status[kind] ?? {}), disabled: false, endpoint,
|
|
317
|
+
lastAttemptAt: at, requestId, requestDeadlineAt: at + timeoutMs + RESERVATION_GRACE_MS }
|
|
318
|
+
data.revision = revisionOf(data)
|
|
319
|
+
state.dynamicData = data
|
|
320
|
+
return true
|
|
262
321
|
})
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
322
|
+
if (!reserved) return
|
|
323
|
+
let candidate
|
|
324
|
+
let error = ''
|
|
325
|
+
try {
|
|
326
|
+
if (invalid) throw new Error('invalid-public-url')
|
|
327
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch-unavailable')
|
|
328
|
+
candidate = await withTimeout(async signal => {
|
|
329
|
+
const fetchPublic = publicFetch(fetchImpl, signal)
|
|
330
|
+
if (kind === 'liveBench') {
|
|
331
|
+
const payload = await fetchBenchmark({ endpoint, fetchImpl: fetchPublic, timeoutMs, signal, strict: true })
|
|
332
|
+
return benchmarkSnapshot(payload, endpoint, now())
|
|
333
|
+
}
|
|
334
|
+
const response = await fetchPublic(endpoint, { signal })
|
|
335
|
+
return pricingSnapshot(JSON.parse(await response.text()), endpoint, now())
|
|
336
|
+
}, controller, timeoutMs)
|
|
337
|
+
} catch { error = 'refresh-failed' }
|
|
338
|
+
await store.update(state => {
|
|
339
|
+
const data = dataOf(state)
|
|
340
|
+
if (data.status[kind]?.requestId !== requestId) return false
|
|
341
|
+
if (candidate && !mayPromote(data[kind], candidate)) error = 'snapshot-rollback-rejected'
|
|
342
|
+
if (candidate && !error) data[kind] = candidate
|
|
343
|
+
data.status[kind] = { ...data.status[kind], error,
|
|
344
|
+
...(candidate && !error ? { lastSuccessAt: now() } : {}) }
|
|
345
|
+
delete data.status[kind].requestId
|
|
346
|
+
delete data.status[kind].requestDeadlineAt
|
|
347
|
+
data.revision = revisionOf(data)
|
|
348
|
+
state.dynamicData = data
|
|
349
|
+
return true
|
|
350
|
+
})
|
|
351
|
+
} finally { active.delete(requestId) }
|
|
276
352
|
}))
|
|
353
|
+
for (let index = 0; index < settled.length; index += 1) if (settled[index].status === 'rejected') storageFailures.add(requests[index].kind)
|
|
277
354
|
const result = dataOf(await store.read())
|
|
355
|
+
for (const kind of storageFailures) {
|
|
356
|
+
const endpoint = sources.find(source => source.kind === kind)?.endpoint ?? ''
|
|
357
|
+
result.status[kind] = { ...(result.status[kind]?.endpoint === endpoint ? result.status[kind] : {}),
|
|
358
|
+
endpoint, error: 'storage-failed' }
|
|
359
|
+
delete result.status[kind].requestId
|
|
360
|
+
delete result.status[kind].requestDeadlineAt
|
|
361
|
+
}
|
|
278
362
|
// A user clearing a source disables its use immediately, without erasing
|
|
279
363
|
// recoverable last-good snapshots from disk.
|
|
280
|
-
for (const { kind, disabled } of sources) {
|
|
281
|
-
if (disabled) {
|
|
282
|
-
result[kind] = null
|
|
283
|
-
result.status[kind] = { disabled: true, endpoint: '', error: '' }
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
364
|
result.revision = revisionOf(result)
|
|
287
|
-
return result
|
|
365
|
+
return projectDynamicData(result, options)
|
|
288
366
|
})()
|
|
289
367
|
flights.set(key, operation)
|
|
290
368
|
try { return await operation }
|
|
@@ -12,6 +12,9 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
|
12
12
|
import { dirname, join } from 'node:path'
|
|
13
13
|
import { resolveStateHome } from './router-state.mjs'
|
|
14
14
|
import { OFFICIAL_TOOLS } from './official-tool-registry.mjs'
|
|
15
|
+
import { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
|
|
16
|
+
|
|
17
|
+
export { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
|
|
15
18
|
|
|
16
19
|
export const LATEST_CACHE_MS = 12 * 60 * 60_000
|
|
17
20
|
export const LATEST_FAILURE_RETRY_MS = 30 * 60_000
|
|
@@ -19,27 +22,6 @@ export const LATEST_TIMEOUT_MS = 5_000
|
|
|
19
22
|
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
20
23
|
export const ZCODE_DOWNLOAD_PAGE = 'https://zcode.z.ai/en/docs/install'
|
|
21
24
|
const MAX_DOCUMENT_BYTES = 2_000_000
|
|
22
|
-
const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,64})?$/
|
|
23
|
-
|
|
24
|
-
export function isReleaseVersion(value) {
|
|
25
|
-
return typeof value === 'string' && VERSION.test(value)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Numeric comparison of x.y.z[-pre]; a prerelease sorts below its release. */
|
|
29
|
-
export function compareReleaseVersions(left, right) {
|
|
30
|
-
const parse = value => {
|
|
31
|
-
const match = VERSION.exec(String(value ?? ''))
|
|
32
|
-
return match ? { parts: match.slice(1, 4).map(Number), pre: String(value).includes('-') } : null
|
|
33
|
-
}
|
|
34
|
-
const a = parse(left)
|
|
35
|
-
const b = parse(right)
|
|
36
|
-
if (!a || !b) return null
|
|
37
|
-
for (let index = 0; index < 3; index += 1) {
|
|
38
|
-
if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1
|
|
39
|
-
}
|
|
40
|
-
if (a.pre !== b.pre) return a.pre ? -1 : 1
|
|
41
|
-
return 0
|
|
42
|
-
}
|
|
43
25
|
|
|
44
26
|
/** True only when both versions parse and the latest is strictly newer. */
|
|
45
27
|
export function updateAvailable(installed, latest) {
|