dsh-dbhub-live 5.2.0-dev.3 → 5.2.0
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/AGENTS.md +116 -116
- package/README.en.md +22 -22
- package/README.md +23 -23
- package/doc/ARCHITECTURE.md +108 -108
- package/doc/CLIENT.md +124 -124
- package/doc/DESENSITIZE.md +56 -56
- package/doc/DEVELOPMENT.md +139 -139
- package/doc/IMPACT-MATRIX.md +36 -36
- package/doc/RELEASE.md +93 -93
- package/lib/meta.mjs +267 -267
- package/lib/policy.mjs +92 -92
- package/lib/update.mjs +244 -244
- package/package.json +1 -1
- package/scripts/desensitize-check.mjs +331 -331
- package/scripts/docs-check.mjs +214 -214
- package/scripts/release-preflight.mjs +180 -180
- package/test/meta.test.mjs +234 -234
- package/test/policy.test.mjs +132 -132
- package/test/update.test.mjs +270 -270
package/lib/update.mjs
CHANGED
|
@@ -1,244 +1,244 @@
|
|
|
1
|
-
// dsh-dbhub-live: the plugin's own update check (npm registry, GitHub fallback).
|
|
2
|
-
//
|
|
3
|
-
// This is a small, self-contained network probe used by the settings card: the
|
|
4
|
-
// Host asks "is a newer version of dsh-dbhub-live published?" and publishes the
|
|
5
|
-
// answer as a JSON view field. It is deliberately isolated from the dbhub
|
|
6
|
-
// runtime: no binary, no store, no DSN, no credentials — the ONLY secrets this
|
|
7
|
-
// module could ever see are none (both endpoints are public reads).
|
|
8
|
-
//
|
|
9
|
-
// Contract: `check()` NEVER rejects. Every failure becomes a short `error`
|
|
10
|
-
// string in the snapshot, and a previously known result is kept (stale) instead
|
|
11
|
-
// of being wiped, so a flaky network never turns the UI's version row blank.
|
|
12
|
-
//
|
|
13
|
-
// Dependency direction: a LEAF that imports only ./meta.mjs (semver helpers).
|
|
14
|
-
// lib/index.mjs imports it; nothing imports back.
|
|
15
|
-
|
|
16
|
-
import { PLUGIN_NAME, isNewer, parseSemver } from './meta.mjs'
|
|
17
|
-
|
|
18
|
-
/** Public npm dist-tag endpoint: `{ version }` for the `latest` tag. */
|
|
19
|
-
export const NPM_LATEST_URL = 'https://registry.npmjs.org/' + PLUGIN_NAME + '/latest'
|
|
20
|
-
|
|
21
|
-
/** Public GitHub releases API: `{ tag_name: 'v5.2.0', ... }`. */
|
|
22
|
-
export const GITHUB_LATEST_URL = 'https://api.github.com/repos/mr-mihu/dsh-dbhub-live/releases/latest'
|
|
23
|
-
|
|
24
|
-
/** Per-request budget. Two sources => at most ~2x this for one full check. */
|
|
25
|
-
export const DEFAULT_TIMEOUT_MS = 5000
|
|
26
|
-
|
|
27
|
-
/** Without `force`, a result younger than this is reused without any request. */
|
|
28
|
-
export const DEFAULT_THROTTLE_MS = 30 * 60 * 1000
|
|
29
|
-
|
|
30
|
-
const REQUEST_HEADERS = {
|
|
31
|
-
accept: 'application/json',
|
|
32
|
-
// GitHub's API rejects requests without a User-Agent (undici sets one, but an
|
|
33
|
-
// explicit value names us in their logs).
|
|
34
|
-
'user-agent': PLUGIN_NAME + '-host',
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Parse a body into an object, tolerating a raw JSON string (fetch stubs). */
|
|
38
|
-
function bodyObject(body) {
|
|
39
|
-
if (body && typeof body === 'object') return body
|
|
40
|
-
if (typeof body !== 'string') return undefined
|
|
41
|
-
try {
|
|
42
|
-
const parsed = JSON.parse(body)
|
|
43
|
-
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
44
|
-
} catch (e) {
|
|
45
|
-
return undefined
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Extract a version from the npm registry body (`{ version: '5.2.0' }`).
|
|
51
|
-
* Pure; '' for any body without a semver-valid version.
|
|
52
|
-
*/
|
|
53
|
-
export function versionFromNpmBody(body) {
|
|
54
|
-
const obj = bodyObject(body)
|
|
55
|
-
const version = obj && typeof obj.version === 'string' ? obj.version.trim() : ''
|
|
56
|
-
return parseSemver(version) ? version : ''
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Extract a version from the GitHub release body (`{ tag_name: 'v5.2.0' }`).
|
|
61
|
-
* The release tag convention in this repo is `v<x.y.z>`, so one leading `v` is
|
|
62
|
-
* stripped before validation. Pure; '' for anything else.
|
|
63
|
-
*/
|
|
64
|
-
export function versionFromGithubBody(body) {
|
|
65
|
-
const obj = bodyObject(body)
|
|
66
|
-
const raw = obj && typeof obj.tag_name === 'string' ? obj.tag_name.trim() : ''
|
|
67
|
-
const version = raw.replace(/^[vV]/, '')
|
|
68
|
-
return parseSemver(version) ? version : ''
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Turn any thrown value into a SHORT, sanitized message suitable for the view
|
|
73
|
-
* (the card renders it inline). Never returns a URL: an error message from a
|
|
74
|
-
* fetch stack can embed the request URL, and the view is browser-facing.
|
|
75
|
-
*/
|
|
76
|
-
function shortError(error) {
|
|
77
|
-
if (error && (error.name === 'AbortError' || error.name === 'TimeoutError' || error.code === 'ABORT_ERR')) {
|
|
78
|
-
return 'timeout'
|
|
79
|
-
}
|
|
80
|
-
let text = String((error && error.message) || error || 'unknown error')
|
|
81
|
-
// Keep the low-level cause code (ENOTFOUND/ECONNREFUSED/...): it is the only
|
|
82
|
-
// actionable part of a Node fetch error ("fetch failed").
|
|
83
|
-
const cause = error && error.cause
|
|
84
|
-
const code = cause && typeof cause.code === 'string' ? cause.code : ''
|
|
85
|
-
text = text.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim()
|
|
86
|
-
if (code && !text.includes(code)) text = text ? text + ' (' + code + ')' : code
|
|
87
|
-
if (text.length > 80) text = text.slice(0, 77) + '...'
|
|
88
|
-
return text || 'request failed'
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Create a lazy update checker.
|
|
93
|
-
*
|
|
94
|
-
* @param {object} [options]
|
|
95
|
-
* @param {Function} [options.fetchImpl] fetch-compatible implementation
|
|
96
|
-
* @param {Function} [options.now] clock (ms); injectable for tests
|
|
97
|
-
* @param {string} [options.npmUrl] npm dist-tag endpoint
|
|
98
|
-
* @param {string} [options.githubUrl] GitHub latest-release endpoint
|
|
99
|
-
* @param {number} [options.timeoutMs] per-request budget
|
|
100
|
-
* @param {number} [options.throttleMs] reuse window for non-forced checks
|
|
101
|
-
* @param {string} [options.currentVersion] installed plugin version to compare against
|
|
102
|
-
* @returns {{ check: (force?: boolean) => Promise<object>, snapshot: () => object }}
|
|
103
|
-
*/
|
|
104
|
-
export function createUpdateChecker(options = {}) {
|
|
105
|
-
const fetchImpl = typeof options.fetchImpl === 'function'
|
|
106
|
-
? options.fetchImpl
|
|
107
|
-
: (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined)
|
|
108
|
-
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
109
|
-
const npmUrl = typeof options.npmUrl === 'string' && options.npmUrl ? options.npmUrl : NPM_LATEST_URL
|
|
110
|
-
const githubUrl = typeof options.githubUrl === 'string' && options.githubUrl
|
|
111
|
-
? options.githubUrl
|
|
112
|
-
: GITHUB_LATEST_URL
|
|
113
|
-
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
114
|
-
? options.timeoutMs
|
|
115
|
-
: DEFAULT_TIMEOUT_MS
|
|
116
|
-
const throttleMs = Number.isFinite(options.throttleMs) && options.throttleMs >= 0
|
|
117
|
-
? options.throttleMs
|
|
118
|
-
: DEFAULT_THROTTLE_MS
|
|
119
|
-
const currentVersion = typeof options.currentVersion === 'string' ? options.currentVersion : ''
|
|
120
|
-
|
|
121
|
-
// Last known result. `latest`/`source` survive a failed re-check on purpose
|
|
122
|
-
// (see the module contract above).
|
|
123
|
-
let state = { latest: '', latestSpec: '', hasUpdate: false, checkedAt: 0, source: '', error: '' }
|
|
124
|
-
let inFlight
|
|
125
|
-
|
|
126
|
-
const snapshot = () => ({ ...state })
|
|
127
|
-
|
|
128
|
-
const safeNow = () => {
|
|
129
|
-
try {
|
|
130
|
-
const value = Number(now())
|
|
131
|
-
return Number.isFinite(value) ? value : Date.now()
|
|
132
|
-
} catch (e) {
|
|
133
|
-
return Date.now()
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const throttled = () => state.checkedAt > 0 && safeNow() - state.checkedAt < throttleMs
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* One request, resolved to a parsed JSON body. A per-request AbortController
|
|
141
|
-
* enforces the budget; the timer stays referenced until the request settles
|
|
142
|
-
* (an unref'd timer would let the process drain before it fires) and is
|
|
143
|
-
* always cleared.
|
|
144
|
-
*/
|
|
145
|
-
const requestJson = async (url) => {
|
|
146
|
-
if (typeof fetchImpl !== 'function') throw new Error('fetch unavailable')
|
|
147
|
-
const controller = typeof AbortController === 'function' ? new AbortController() : undefined
|
|
148
|
-
const timer = controller
|
|
149
|
-
? setTimeout(() => {
|
|
150
|
-
try {
|
|
151
|
-
controller.abort()
|
|
152
|
-
} catch (e) {
|
|
153
|
-
/* already aborted */
|
|
154
|
-
}
|
|
155
|
-
}, timeoutMs)
|
|
156
|
-
: undefined
|
|
157
|
-
try {
|
|
158
|
-
const response = await fetchImpl(url, {
|
|
159
|
-
headers: REQUEST_HEADERS,
|
|
160
|
-
signal: controller ? controller.signal : undefined,
|
|
161
|
-
})
|
|
162
|
-
if (!response || typeof response !== 'object') throw new Error('empty response')
|
|
163
|
-
const status = typeof response.status === 'number' ? response.status : 0
|
|
164
|
-
const ok = typeof response.ok === 'boolean'
|
|
165
|
-
? response.ok
|
|
166
|
-
: status === 0 || (status >= 200 && status < 300)
|
|
167
|
-
if (!ok) throw new Error('HTTP ' + (status || 'error'))
|
|
168
|
-
if (typeof response.json === 'function') return await response.json()
|
|
169
|
-
if (typeof response.text === 'function') {
|
|
170
|
-
const text = await response.text()
|
|
171
|
-
return text ? JSON.parse(text) : undefined
|
|
172
|
-
}
|
|
173
|
-
return undefined
|
|
174
|
-
} finally {
|
|
175
|
-
if (timer) clearTimeout(timer)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Try one source; return the version or '' after recording its error. */
|
|
180
|
-
const attempt = async (label, url, extract, errors) => {
|
|
181
|
-
try {
|
|
182
|
-
const version = extract(await requestJson(url))
|
|
183
|
-
if (!version) throw new Error('invalid version in response')
|
|
184
|
-
return version
|
|
185
|
-
} catch (e) {
|
|
186
|
-
errors.push(label + ': ' + shortError(e))
|
|
187
|
-
return ''
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const runCheck = async () => {
|
|
192
|
-
const errors = []
|
|
193
|
-
let latest = ''
|
|
194
|
-
let source = ''
|
|
195
|
-
try {
|
|
196
|
-
latest = await attempt('npm', npmUrl, versionFromNpmBody, errors)
|
|
197
|
-
if (latest) source = 'npm'
|
|
198
|
-
if (!latest) {
|
|
199
|
-
latest = await attempt('github', githubUrl, versionFromGithubBody, errors)
|
|
200
|
-
if (latest) source = 'github'
|
|
201
|
-
}
|
|
202
|
-
} catch (e) {
|
|
203
|
-
// Belt and braces: `attempt` already absorbs its own failures.
|
|
204
|
-
errors.push(shortError(e))
|
|
205
|
-
}
|
|
206
|
-
const checkedAt = safeNow()
|
|
207
|
-
if (latest) {
|
|
208
|
-
state = {
|
|
209
|
-
latest,
|
|
210
|
-
latestSpec: PLUGIN_NAME + '@' + latest,
|
|
211
|
-
hasUpdate: isNewer(latest, currentVersion),
|
|
212
|
-
checkedAt,
|
|
213
|
-
source,
|
|
214
|
-
error: '',
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
state = {
|
|
218
|
-
// Keep whatever we knew: a transient failure is not "no release".
|
|
219
|
-
latest: state.latest,
|
|
220
|
-
latestSpec: state.latestSpec,
|
|
221
|
-
hasUpdate: state.hasUpdate,
|
|
222
|
-
checkedAt,
|
|
223
|
-
source: state.source,
|
|
224
|
-
error: errors.join('; ') || 'no version found',
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return snapshot()
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Run (or reuse) the check. Concurrent calls share one in-flight promise, so
|
|
232
|
-
* a double click or a page reload cannot double the request burst.
|
|
233
|
-
*/
|
|
234
|
-
const check = (force) => {
|
|
235
|
-
if (inFlight) return inFlight
|
|
236
|
-
if (!force && throttled()) return Promise.resolve(snapshot())
|
|
237
|
-
inFlight = runCheck().finally(() => {
|
|
238
|
-
inFlight = undefined
|
|
239
|
-
})
|
|
240
|
-
return inFlight
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return { check, snapshot }
|
|
244
|
-
}
|
|
1
|
+
// dsh-dbhub-live: the plugin's own update check (npm registry, GitHub fallback).
|
|
2
|
+
//
|
|
3
|
+
// This is a small, self-contained network probe used by the settings card: the
|
|
4
|
+
// Host asks "is a newer version of dsh-dbhub-live published?" and publishes the
|
|
5
|
+
// answer as a JSON view field. It is deliberately isolated from the dbhub
|
|
6
|
+
// runtime: no binary, no store, no DSN, no credentials — the ONLY secrets this
|
|
7
|
+
// module could ever see are none (both endpoints are public reads).
|
|
8
|
+
//
|
|
9
|
+
// Contract: `check()` NEVER rejects. Every failure becomes a short `error`
|
|
10
|
+
// string in the snapshot, and a previously known result is kept (stale) instead
|
|
11
|
+
// of being wiped, so a flaky network never turns the UI's version row blank.
|
|
12
|
+
//
|
|
13
|
+
// Dependency direction: a LEAF that imports only ./meta.mjs (semver helpers).
|
|
14
|
+
// lib/index.mjs imports it; nothing imports back.
|
|
15
|
+
|
|
16
|
+
import { PLUGIN_NAME, isNewer, parseSemver } from './meta.mjs'
|
|
17
|
+
|
|
18
|
+
/** Public npm dist-tag endpoint: `{ version }` for the `latest` tag. */
|
|
19
|
+
export const NPM_LATEST_URL = 'https://registry.npmjs.org/' + PLUGIN_NAME + '/latest'
|
|
20
|
+
|
|
21
|
+
/** Public GitHub releases API: `{ tag_name: 'v5.2.0', ... }`. */
|
|
22
|
+
export const GITHUB_LATEST_URL = 'https://api.github.com/repos/mr-mihu/dsh-dbhub-live/releases/latest'
|
|
23
|
+
|
|
24
|
+
/** Per-request budget. Two sources => at most ~2x this for one full check. */
|
|
25
|
+
export const DEFAULT_TIMEOUT_MS = 5000
|
|
26
|
+
|
|
27
|
+
/** Without `force`, a result younger than this is reused without any request. */
|
|
28
|
+
export const DEFAULT_THROTTLE_MS = 30 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
const REQUEST_HEADERS = {
|
|
31
|
+
accept: 'application/json',
|
|
32
|
+
// GitHub's API rejects requests without a User-Agent (undici sets one, but an
|
|
33
|
+
// explicit value names us in their logs).
|
|
34
|
+
'user-agent': PLUGIN_NAME + '-host',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse a body into an object, tolerating a raw JSON string (fetch stubs). */
|
|
38
|
+
function bodyObject(body) {
|
|
39
|
+
if (body && typeof body === 'object') return body
|
|
40
|
+
if (typeof body !== 'string') return undefined
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(body)
|
|
43
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract a version from the npm registry body (`{ version: '5.2.0' }`).
|
|
51
|
+
* Pure; '' for any body without a semver-valid version.
|
|
52
|
+
*/
|
|
53
|
+
export function versionFromNpmBody(body) {
|
|
54
|
+
const obj = bodyObject(body)
|
|
55
|
+
const version = obj && typeof obj.version === 'string' ? obj.version.trim() : ''
|
|
56
|
+
return parseSemver(version) ? version : ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract a version from the GitHub release body (`{ tag_name: 'v5.2.0' }`).
|
|
61
|
+
* The release tag convention in this repo is `v<x.y.z>`, so one leading `v` is
|
|
62
|
+
* stripped before validation. Pure; '' for anything else.
|
|
63
|
+
*/
|
|
64
|
+
export function versionFromGithubBody(body) {
|
|
65
|
+
const obj = bodyObject(body)
|
|
66
|
+
const raw = obj && typeof obj.tag_name === 'string' ? obj.tag_name.trim() : ''
|
|
67
|
+
const version = raw.replace(/^[vV]/, '')
|
|
68
|
+
return parseSemver(version) ? version : ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn any thrown value into a SHORT, sanitized message suitable for the view
|
|
73
|
+
* (the card renders it inline). Never returns a URL: an error message from a
|
|
74
|
+
* fetch stack can embed the request URL, and the view is browser-facing.
|
|
75
|
+
*/
|
|
76
|
+
function shortError(error) {
|
|
77
|
+
if (error && (error.name === 'AbortError' || error.name === 'TimeoutError' || error.code === 'ABORT_ERR')) {
|
|
78
|
+
return 'timeout'
|
|
79
|
+
}
|
|
80
|
+
let text = String((error && error.message) || error || 'unknown error')
|
|
81
|
+
// Keep the low-level cause code (ENOTFOUND/ECONNREFUSED/...): it is the only
|
|
82
|
+
// actionable part of a Node fetch error ("fetch failed").
|
|
83
|
+
const cause = error && error.cause
|
|
84
|
+
const code = cause && typeof cause.code === 'string' ? cause.code : ''
|
|
85
|
+
text = text.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim()
|
|
86
|
+
if (code && !text.includes(code)) text = text ? text + ' (' + code + ')' : code
|
|
87
|
+
if (text.length > 80) text = text.slice(0, 77) + '...'
|
|
88
|
+
return text || 'request failed'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a lazy update checker.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} [options]
|
|
95
|
+
* @param {Function} [options.fetchImpl] fetch-compatible implementation
|
|
96
|
+
* @param {Function} [options.now] clock (ms); injectable for tests
|
|
97
|
+
* @param {string} [options.npmUrl] npm dist-tag endpoint
|
|
98
|
+
* @param {string} [options.githubUrl] GitHub latest-release endpoint
|
|
99
|
+
* @param {number} [options.timeoutMs] per-request budget
|
|
100
|
+
* @param {number} [options.throttleMs] reuse window for non-forced checks
|
|
101
|
+
* @param {string} [options.currentVersion] installed plugin version to compare against
|
|
102
|
+
* @returns {{ check: (force?: boolean) => Promise<object>, snapshot: () => object }}
|
|
103
|
+
*/
|
|
104
|
+
export function createUpdateChecker(options = {}) {
|
|
105
|
+
const fetchImpl = typeof options.fetchImpl === 'function'
|
|
106
|
+
? options.fetchImpl
|
|
107
|
+
: (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined)
|
|
108
|
+
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
109
|
+
const npmUrl = typeof options.npmUrl === 'string' && options.npmUrl ? options.npmUrl : NPM_LATEST_URL
|
|
110
|
+
const githubUrl = typeof options.githubUrl === 'string' && options.githubUrl
|
|
111
|
+
? options.githubUrl
|
|
112
|
+
: GITHUB_LATEST_URL
|
|
113
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
114
|
+
? options.timeoutMs
|
|
115
|
+
: DEFAULT_TIMEOUT_MS
|
|
116
|
+
const throttleMs = Number.isFinite(options.throttleMs) && options.throttleMs >= 0
|
|
117
|
+
? options.throttleMs
|
|
118
|
+
: DEFAULT_THROTTLE_MS
|
|
119
|
+
const currentVersion = typeof options.currentVersion === 'string' ? options.currentVersion : ''
|
|
120
|
+
|
|
121
|
+
// Last known result. `latest`/`source` survive a failed re-check on purpose
|
|
122
|
+
// (see the module contract above).
|
|
123
|
+
let state = { latest: '', latestSpec: '', hasUpdate: false, checkedAt: 0, source: '', error: '' }
|
|
124
|
+
let inFlight
|
|
125
|
+
|
|
126
|
+
const snapshot = () => ({ ...state })
|
|
127
|
+
|
|
128
|
+
const safeNow = () => {
|
|
129
|
+
try {
|
|
130
|
+
const value = Number(now())
|
|
131
|
+
return Number.isFinite(value) ? value : Date.now()
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return Date.now()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const throttled = () => state.checkedAt > 0 && safeNow() - state.checkedAt < throttleMs
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One request, resolved to a parsed JSON body. A per-request AbortController
|
|
141
|
+
* enforces the budget; the timer stays referenced until the request settles
|
|
142
|
+
* (an unref'd timer would let the process drain before it fires) and is
|
|
143
|
+
* always cleared.
|
|
144
|
+
*/
|
|
145
|
+
const requestJson = async (url) => {
|
|
146
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch unavailable')
|
|
147
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : undefined
|
|
148
|
+
const timer = controller
|
|
149
|
+
? setTimeout(() => {
|
|
150
|
+
try {
|
|
151
|
+
controller.abort()
|
|
152
|
+
} catch (e) {
|
|
153
|
+
/* already aborted */
|
|
154
|
+
}
|
|
155
|
+
}, timeoutMs)
|
|
156
|
+
: undefined
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetchImpl(url, {
|
|
159
|
+
headers: REQUEST_HEADERS,
|
|
160
|
+
signal: controller ? controller.signal : undefined,
|
|
161
|
+
})
|
|
162
|
+
if (!response || typeof response !== 'object') throw new Error('empty response')
|
|
163
|
+
const status = typeof response.status === 'number' ? response.status : 0
|
|
164
|
+
const ok = typeof response.ok === 'boolean'
|
|
165
|
+
? response.ok
|
|
166
|
+
: status === 0 || (status >= 200 && status < 300)
|
|
167
|
+
if (!ok) throw new Error('HTTP ' + (status || 'error'))
|
|
168
|
+
if (typeof response.json === 'function') return await response.json()
|
|
169
|
+
if (typeof response.text === 'function') {
|
|
170
|
+
const text = await response.text()
|
|
171
|
+
return text ? JSON.parse(text) : undefined
|
|
172
|
+
}
|
|
173
|
+
return undefined
|
|
174
|
+
} finally {
|
|
175
|
+
if (timer) clearTimeout(timer)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Try one source; return the version or '' after recording its error. */
|
|
180
|
+
const attempt = async (label, url, extract, errors) => {
|
|
181
|
+
try {
|
|
182
|
+
const version = extract(await requestJson(url))
|
|
183
|
+
if (!version) throw new Error('invalid version in response')
|
|
184
|
+
return version
|
|
185
|
+
} catch (e) {
|
|
186
|
+
errors.push(label + ': ' + shortError(e))
|
|
187
|
+
return ''
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const runCheck = async () => {
|
|
192
|
+
const errors = []
|
|
193
|
+
let latest = ''
|
|
194
|
+
let source = ''
|
|
195
|
+
try {
|
|
196
|
+
latest = await attempt('npm', npmUrl, versionFromNpmBody, errors)
|
|
197
|
+
if (latest) source = 'npm'
|
|
198
|
+
if (!latest) {
|
|
199
|
+
latest = await attempt('github', githubUrl, versionFromGithubBody, errors)
|
|
200
|
+
if (latest) source = 'github'
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Belt and braces: `attempt` already absorbs its own failures.
|
|
204
|
+
errors.push(shortError(e))
|
|
205
|
+
}
|
|
206
|
+
const checkedAt = safeNow()
|
|
207
|
+
if (latest) {
|
|
208
|
+
state = {
|
|
209
|
+
latest,
|
|
210
|
+
latestSpec: PLUGIN_NAME + '@' + latest,
|
|
211
|
+
hasUpdate: isNewer(latest, currentVersion),
|
|
212
|
+
checkedAt,
|
|
213
|
+
source,
|
|
214
|
+
error: '',
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
state = {
|
|
218
|
+
// Keep whatever we knew: a transient failure is not "no release".
|
|
219
|
+
latest: state.latest,
|
|
220
|
+
latestSpec: state.latestSpec,
|
|
221
|
+
hasUpdate: state.hasUpdate,
|
|
222
|
+
checkedAt,
|
|
223
|
+
source: state.source,
|
|
224
|
+
error: errors.join('; ') || 'no version found',
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return snapshot()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run (or reuse) the check. Concurrent calls share one in-flight promise, so
|
|
232
|
+
* a double click or a page reload cannot double the request burst.
|
|
233
|
+
*/
|
|
234
|
+
const check = (force) => {
|
|
235
|
+
if (inFlight) return inFlight
|
|
236
|
+
if (!force && throttled()) return Promise.resolve(snapshot())
|
|
237
|
+
inFlight = runCheck().finally(() => {
|
|
238
|
+
inFlight = undefined
|
|
239
|
+
})
|
|
240
|
+
return inFlight
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { check, snapshot }
|
|
244
|
+
}
|
package/package.json
CHANGED