@huaqiu/dsh-tool-plugin-update 0.0.0-stage → 0.5.21
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/LICENSE +21 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1006 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +32 -0
- package/lib/index.mjs +178 -0
- package/package.json +50 -3
- package/src/client/api.ts +94 -0
- package/src/client/copy.ts +92 -0
- package/src/client/index.ts +508 -0
- package/src/client/update-view.ts +301 -0
- package/src/index.ts +108 -0
- package/src/routes.ts +149 -0
- package/README.md +0 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-update review modal (browser half).
|
|
3
|
+
*
|
|
4
|
+
* Plain-DOM modal (no React tree) so it can never disturb the DSH shell's
|
|
5
|
+
* reconciliation — same rule as the sidebar entry rows. Rendered content:
|
|
6
|
+
*
|
|
7
|
+
* - header: title + refresh
|
|
8
|
+
* - plugin list (name, current → available, state badge, blocked reason)
|
|
9
|
+
* - ignored-offer banner + reversible "show again"
|
|
10
|
+
* - install action (disabled while installing / nothing actionable / offer
|
|
11
|
+
* ignored); after install the UI keeps polling the backend state instead
|
|
12
|
+
* of trusting the HTTP response alone, because activating a set restarts
|
|
13
|
+
* DSH (spec §7.1: never report success on the 200/202 alone).
|
|
14
|
+
* - independent HQ Edge runtime section (read-only report; the actual
|
|
15
|
+
* upgrade is owned by the EDA installer).
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
checkUpdates,
|
|
21
|
+
ignoreOffer,
|
|
22
|
+
installSet,
|
|
23
|
+
unignoreOffer,
|
|
24
|
+
type PluginUpdateItem,
|
|
25
|
+
type PluginUpdateReport,
|
|
26
|
+
} from './api.js'
|
|
27
|
+
import type { UpdateCopy } from './copy.js'
|
|
28
|
+
|
|
29
|
+
export interface UpdateStoreLike {
|
|
30
|
+
report(): PluginUpdateReport | null
|
|
31
|
+
installing(): boolean
|
|
32
|
+
refresh(): Promise<PluginUpdateReport | null>
|
|
33
|
+
subscribe(listener: () => void): () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function el<K extends keyof HTMLElementTagNameMap>(
|
|
37
|
+
tag: K,
|
|
38
|
+
className: string,
|
|
39
|
+
text?: string,
|
|
40
|
+
): HTMLElementTagNameMap[K] {
|
|
41
|
+
const node = document.createElement(tag)
|
|
42
|
+
node.className = className
|
|
43
|
+
if (text !== undefined) node.textContent = text
|
|
44
|
+
return node
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
|
|
48
|
+
switch (item.state) {
|
|
49
|
+
case 'current':
|
|
50
|
+
return copy.state.current
|
|
51
|
+
case 'update':
|
|
52
|
+
return copy.state.update
|
|
53
|
+
case 'install':
|
|
54
|
+
return copy.state.install
|
|
55
|
+
case 'ignored':
|
|
56
|
+
return copy.state.ignored
|
|
57
|
+
case 'blocked':
|
|
58
|
+
return copy.state.blocked
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undefined {
|
|
63
|
+
const by = item.blockedBy ?? []
|
|
64
|
+
if (by.includes('host')) return copy.blockedReason.host
|
|
65
|
+
if (by.includes('hq-edge-version')) return copy.blockedReason['hq-edge-version']
|
|
66
|
+
if (by.includes('runtime-unknown')) return copy.blockedReason['runtime-unknown']
|
|
67
|
+
return by.length > 0 ? by.join(', ') : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Open (or focus) the update modal. Re-renders from the store; when the store
|
|
72
|
+
* is stale the modal first refreshes the report.
|
|
73
|
+
*/
|
|
74
|
+
export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
75
|
+
const existing = document.querySelector<HTMLElement>('[data-hq-plugin-update-view]')
|
|
76
|
+
if (existing !== null) {
|
|
77
|
+
existing.style.display = ''
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const overlay = el('div', 'hqpu-overlay')
|
|
82
|
+
overlay.setAttribute('data-hq-plugin-update-view', '')
|
|
83
|
+
const panel = el('div', 'hqpu-panel')
|
|
84
|
+
|
|
85
|
+
// Header
|
|
86
|
+
const header = el('div', 'hqpu-header')
|
|
87
|
+
header.append(el('div', 'hqpu-title', copy.viewTitle))
|
|
88
|
+
const refreshBtn = el('button', 'hqpu-btn', copy.refresh)
|
|
89
|
+
header.append(refreshBtn)
|
|
90
|
+
const closeBtn = el('button', 'hqpu-btn hqpu-btn-ghost', copy.close)
|
|
91
|
+
header.append(closeBtn)
|
|
92
|
+
|
|
93
|
+
// Body: plugin list + ignored banner + install action + runtime section.
|
|
94
|
+
const body = el('div', 'hqpu-body')
|
|
95
|
+
const listSection = el('div', 'hqpu-section')
|
|
96
|
+
const runtimeSection = el('div', 'hqpu-section hqpu-runtime')
|
|
97
|
+
const actionBar = el('div', 'hqpu-actions')
|
|
98
|
+
|
|
99
|
+
panel.append(header, body)
|
|
100
|
+
body.append(listSection, runtimeSection, actionBar)
|
|
101
|
+
overlay.append(panel)
|
|
102
|
+
document.body.appendChild(overlay)
|
|
103
|
+
|
|
104
|
+
const disposers: Array<() => void> = []
|
|
105
|
+
|
|
106
|
+
const render = (): void => {
|
|
107
|
+
const report = store.report()
|
|
108
|
+
const installing = store.installing()
|
|
109
|
+
renderList(listSection, report, copy, installing, store)
|
|
110
|
+
renderRuntime(runtimeSection, report, copy)
|
|
111
|
+
renderActions(actionBar, report, copy, store, installing)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const unsubscribe = store.subscribe(render)
|
|
115
|
+
disposers.push(unsubscribe)
|
|
116
|
+
|
|
117
|
+
// Refresh on open (bounded; never block the shell).
|
|
118
|
+
void store.refresh().then(() => {
|
|
119
|
+
// Store notification re-renders already; nothing extra to do.
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const onClose = (): void => {
|
|
123
|
+
for (const dispose of disposers) {
|
|
124
|
+
try {
|
|
125
|
+
dispose()
|
|
126
|
+
} catch {
|
|
127
|
+
/* already disposed */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
overlay.remove()
|
|
131
|
+
}
|
|
132
|
+
closeBtn.addEventListener('click', onClose)
|
|
133
|
+
overlay.addEventListener('click', (ev) => {
|
|
134
|
+
if (ev.target === overlay) onClose()
|
|
135
|
+
})
|
|
136
|
+
refreshBtn.addEventListener('click', () => {
|
|
137
|
+
void store.refresh()
|
|
138
|
+
})
|
|
139
|
+
disposers.push(() => {
|
|
140
|
+
closeBtn.removeEventListener('click', onClose)
|
|
141
|
+
overlay.removeEventListener('click', onClose)
|
|
142
|
+
refreshBtn.removeEventListener('click', () => {})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
render()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderList(
|
|
149
|
+
section: HTMLElement,
|
|
150
|
+
report: PluginUpdateReport | null,
|
|
151
|
+
copy: UpdateCopy,
|
|
152
|
+
installing: boolean,
|
|
153
|
+
store: UpdateStoreLike,
|
|
154
|
+
): void {
|
|
155
|
+
section.textContent = ''
|
|
156
|
+
if (report === null) {
|
|
157
|
+
section.append(el('div', 'hqpu-empty', copy.checkFailed))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (report.items.length === 0) {
|
|
161
|
+
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const manifestRow = el('div', 'hqpu-manifest')
|
|
166
|
+
manifestRow.append(
|
|
167
|
+
el('span', 'hqpu-manifest-label', `${copy.manifestVersion}`),
|
|
168
|
+
el('span', 'hqpu-manifest-value', report.manifestVersion),
|
|
169
|
+
)
|
|
170
|
+
section.append(manifestRow)
|
|
171
|
+
|
|
172
|
+
if (report.ignoredSet) {
|
|
173
|
+
const banner = el('div', 'hqpu-ignored-banner', copy.ignoredOffer)
|
|
174
|
+
const unignore = el('button', 'hqpu-btn hqpu-btn-small', copy.unignore)
|
|
175
|
+
unignore.addEventListener('click', () => {
|
|
176
|
+
void unignoreOffer(report.manifestVersion)
|
|
177
|
+
.then(() => store.refresh())
|
|
178
|
+
.catch(() => undefined)
|
|
179
|
+
})
|
|
180
|
+
banner.append(unignore)
|
|
181
|
+
section.append(banner)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const list = el('ul', 'hqpu-list')
|
|
185
|
+
for (const item of report.items) {
|
|
186
|
+
const row = el('li', `hqpu-item hqpu-item-${item.state}`)
|
|
187
|
+
const name = el('span', 'hqpu-item-name', item.name)
|
|
188
|
+
const version = el(
|
|
189
|
+
'span',
|
|
190
|
+
'hqpu-item-version',
|
|
191
|
+
item.state === 'current'
|
|
192
|
+
? item.currentVersion
|
|
193
|
+
: `${item.currentVersion} → ${item.availableVersion}`,
|
|
194
|
+
)
|
|
195
|
+
const state = el('span', `hqpu-item-state hqpu-state-${item.state}`, stateLabel(item, copy))
|
|
196
|
+
row.append(name, version, state)
|
|
197
|
+
const reason = blockedReason(item, copy)
|
|
198
|
+
if (reason !== undefined) {
|
|
199
|
+
row.append(el('span', 'hqpu-item-reason', reason))
|
|
200
|
+
}
|
|
201
|
+
list.append(row)
|
|
202
|
+
}
|
|
203
|
+
section.append(list)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderRuntime(
|
|
207
|
+
section: HTMLElement,
|
|
208
|
+
report: PluginUpdateReport | null,
|
|
209
|
+
copy: UpdateCopy,
|
|
210
|
+
): void {
|
|
211
|
+
section.textContent = ''
|
|
212
|
+
const title = el('div', 'hqpu-runtime-title', copy.runtime.title)
|
|
213
|
+
section.append(title)
|
|
214
|
+
const runtime = report?.runtime
|
|
215
|
+
if (runtime === undefined || runtime.configured === false) {
|
|
216
|
+
section.append(el('div', 'hqpu-runtime-line', copy.runtime.notConfigured))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (runtime.updateAvailable && runtime.latest !== undefined) {
|
|
220
|
+
section.append(el('div', 'hqpu-runtime-line', copy.runtime.updateAvailable(runtime.latest)))
|
|
221
|
+
} else if (runtime.currentVersion !== undefined) {
|
|
222
|
+
section.append(el('div', 'hqpu-runtime-line', copy.runtime.current(runtime.currentVersion)))
|
|
223
|
+
} else {
|
|
224
|
+
section.append(el('div', 'hqpu-runtime-line', copy.runtime.unknown))
|
|
225
|
+
}
|
|
226
|
+
section.append(el('div', 'hqpu-runtime-note', copy.runtime.note))
|
|
227
|
+
if (runtime.lastError !== undefined && runtime.lastError.length > 0) {
|
|
228
|
+
section.append(el('div', 'hqpu-runtime-error', `${copy.lastError}: ${runtime.lastError}`))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderActions(
|
|
233
|
+
bar: HTMLElement,
|
|
234
|
+
report: PluginUpdateReport | null,
|
|
235
|
+
copy: UpdateCopy,
|
|
236
|
+
store: UpdateStoreLike,
|
|
237
|
+
installing: boolean,
|
|
238
|
+
): void {
|
|
239
|
+
bar.textContent = ''
|
|
240
|
+
if (report === null) return
|
|
241
|
+
|
|
242
|
+
if (installing) {
|
|
243
|
+
bar.append(el('span', 'hqpu-installing', copy.installing))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const actionable = report.items.filter(
|
|
248
|
+
(item) => item.state === 'update' || item.state === 'install',
|
|
249
|
+
).length
|
|
250
|
+
const installBtn = el(
|
|
251
|
+
'button',
|
|
252
|
+
'hqpu-btn hqpu-btn-primary',
|
|
253
|
+
actionable > 0 ? copy.installAll(actionable) : copy.state.current,
|
|
254
|
+
)
|
|
255
|
+
installBtn.disabled = actionable === 0 || report.ignoredSet
|
|
256
|
+
installBtn.addEventListener('click', () => {
|
|
257
|
+
void installSet(report.manifestVersion)
|
|
258
|
+
.then((result) => {
|
|
259
|
+
if (result.error !== undefined) {
|
|
260
|
+
bar.textContent = ''
|
|
261
|
+
bar.append(el('div', 'hqpu-error', `${copy.installError}: ${result.error}`))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
// The set is activated atomically; DSH restarts. Keep polling the
|
|
265
|
+
// backend state rather than trusting the response alone (spec §7.1).
|
|
266
|
+
bar.textContent = ''
|
|
267
|
+
bar.append(el('span', 'hqpu-installing', copy.installSuccess))
|
|
268
|
+
void pollUntilSettled(store, report.manifestVersion)
|
|
269
|
+
})
|
|
270
|
+
.catch((err: unknown) => {
|
|
271
|
+
bar.textContent = ''
|
|
272
|
+
bar.append(
|
|
273
|
+
el('div', 'hqpu-error', `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`),
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
bar.append(installBtn)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** After an install, poll the backend until the update report settles
|
|
281
|
+
* (installing flag clears or DSH restarts under us — the next load re-reads
|
|
282
|
+
* the fresh state). */
|
|
283
|
+
function pollUntilSettled(store: UpdateStoreLike, setVersion: string): void {
|
|
284
|
+
let attempts = 0
|
|
285
|
+
const tick = (): void => {
|
|
286
|
+
void store
|
|
287
|
+
.refresh()
|
|
288
|
+
.then((report) => {
|
|
289
|
+
if (store.installing()) return
|
|
290
|
+
if (report !== null && report.ignoredSet === false && attempts >= 2) return
|
|
291
|
+
})
|
|
292
|
+
.catch(() => undefined)
|
|
293
|
+
.finally(() => {
|
|
294
|
+
attempts += 1
|
|
295
|
+
if (attempts < 10) {
|
|
296
|
+
window.setTimeout(tick, 2000)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
tick()
|
|
301
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Huaqiu EDA plugin-update DSH plugin (node half) — `@huaqiu/dsh-tool-plugin-update`.
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter between the DSH Web update UI and the hq-edge plugin-update
|
|
5
|
+
* API (task dsh-plugin-discovery-and-update-with-hq-edge-update-reporting.md
|
|
6
|
+
* §7). The browser half mounts the sidebar toolbutton (badge = actionable
|
|
7
|
+
* count) and the review/confirm/ignore view; every request it makes lands on
|
|
8
|
+
* this package's same-origin prefix routes, which forward to hq-edge.
|
|
9
|
+
*
|
|
10
|
+
* ── Architectural boundary ─────────────────────────────────────────────────
|
|
11
|
+
* All update logic is owned ONCE by hq-edge (discovery, compatibility,
|
|
12
|
+
* ignore persistence, the install transaction with cross-process lock and
|
|
13
|
+
* atomic activation). This plugin is a thin adapter: no manifest fetching, no
|
|
14
|
+
* registry calls, no download/verify code, no `@hqedge/*` dependency. It only
|
|
15
|
+
* talks to hq-edge through the edge-bridge (`ctx.hqEdge.baseUrl`), matching
|
|
16
|
+
* the `@huaqiu/dsh-tool-quote` / `@huaqiu/dsh-eda-host` convention.
|
|
17
|
+
*
|
|
18
|
+
* `hqEdge` is a REQUIRED inject: without the bridge the update UI reports
|
|
19
|
+
* "hq-edge is not reachable" rather than showing stale data.
|
|
20
|
+
*
|
|
21
|
+
* @module @huaqiu/dsh-tool-plugin-update
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
26
|
+
import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
|
|
27
|
+
import { createUpdateForwarder, UPDATE_ROUTE_PREFIX } from './routes.js'
|
|
28
|
+
|
|
29
|
+
/** Plugin id — matches package.json. */
|
|
30
|
+
export const name = '@huaqiu/dsh-tool-plugin-update'
|
|
31
|
+
|
|
32
|
+
/** Cordis services this half depends on: hq-edge bridge (REQUIRED) + the DSH
|
|
33
|
+
* webServer surface that hosts the same-origin forwarding routes. */
|
|
34
|
+
export const inject = ['hqEdge', 'webServer'] as const
|
|
35
|
+
|
|
36
|
+
declare module '@deepseek-ai/cordis' {
|
|
37
|
+
interface Context {
|
|
38
|
+
/**
|
|
39
|
+
* Provided by the hq-edge `edge-bridge` plugin (the HOST). `baseUrl` is
|
|
40
|
+
* the loopback HQ Edge endpoint, e.g. "http://localhost:18080". Read
|
|
41
|
+
* lazily on every request.
|
|
42
|
+
*/
|
|
43
|
+
hqEdge: { baseUrl?: string }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared component name for the unified DSH-plugin log. */
|
|
48
|
+
const COMPONENT = 'dsh-tool-plugin-update'
|
|
49
|
+
|
|
50
|
+
let _log: PluginLogger | null = null
|
|
51
|
+
function log(): PluginLogger {
|
|
52
|
+
if (_log === null) _log = getLogger(COMPONENT)
|
|
53
|
+
return _log
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Host plugin body — register the forwarding routes.
|
|
58
|
+
*
|
|
59
|
+
* The HQ Edge endpoint is resolved lazily per request from `ctx.hqEdge`
|
|
60
|
+
* (edge-bridge). When the bridge is absent the plugin refuses to load: an
|
|
61
|
+
* update UI without a host would only mislead.
|
|
62
|
+
*
|
|
63
|
+
* @param ctx - real cordis context (node side).
|
|
64
|
+
* @returns disposer — unregisters the routes on plugin dispose.
|
|
65
|
+
*/
|
|
66
|
+
export function apply(ctx: Context): () => void {
|
|
67
|
+
const hq = ctx.hqEdge
|
|
68
|
+
if (!hq || typeof hq.baseUrl !== 'string' || hq.baseUrl.trim().length === 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'@huaqiu/dsh-tool-plugin-update requires a usable hq-edge context: the ' +
|
|
71
|
+
'edge-bridge plugin did not provide ctx.hqEdge.baseUrl. The update UI ' +
|
|
72
|
+
'cannot work without hq-edge — check that the bridge started.',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const getHqEdgeBaseUrl = (): string | undefined => {
|
|
77
|
+
const current = ctx.hqEdge
|
|
78
|
+
return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const disposers: Array<() => void> = []
|
|
82
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
83
|
+
ctx.effect(() =>
|
|
84
|
+
ctx.webServer.register({
|
|
85
|
+
kind: 'prefix',
|
|
86
|
+
path: UPDATE_ROUTE_PREFIX,
|
|
87
|
+
handler: createUpdateForwarder(getHqEdgeBaseUrl),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
} else {
|
|
91
|
+
log().warn('webServer unavailable — plugin-update routes are not exposed; the sidebar UI cannot load data')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
log().info('applying dsh-tool-plugin-update node half', {
|
|
95
|
+
routePrefix: UPDATE_ROUTE_PREFIX,
|
|
96
|
+
hqEdgeBaseUrl: getHqEdgeBaseUrl() ?? null,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
for (const dispose of disposers) {
|
|
101
|
+
try {
|
|
102
|
+
dispose()
|
|
103
|
+
} catch {
|
|
104
|
+
// best-effort teardown
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin forwarding routes for the plugin-update UI (browser half).
|
|
3
|
+
*
|
|
4
|
+
* Browser → DSH webServer prefix route `/api/v1/huaqiu/plugin-update/…` →
|
|
5
|
+
* this handler → hq-edge loopback HTTP API `/api/v1/plugin-updates/…`.
|
|
6
|
+
*
|
|
7
|
+
* The forwarding is a THIN adapter: hq-edge owns discovery, compatibility,
|
|
8
|
+
* ignore persistence and the install transaction (with its loopback-only
|
|
9
|
+
* protection — every request below originates from this node process on the
|
|
10
|
+
* same host, so the loopback guard is satisfied naturally). This plugin
|
|
11
|
+
* carries no update logic and no `@hqedge/*` dependency.
|
|
12
|
+
*
|
|
13
|
+
* Routes (mirroring hq-edge apps/server/src/routes/plugin-updates.ts):
|
|
14
|
+
* GET /plugin-update/report → GET /api/v1/plugin-updates
|
|
15
|
+
* POST /plugin-update/check → POST /api/v1/plugin-updates/check
|
|
16
|
+
* POST /plugin-update/install → POST /api/v1/plugin-updates/install {setVersion}
|
|
17
|
+
* POST /plugin-update/ignore → POST /api/v1/plugin-updates/ignore {setVersion}
|
|
18
|
+
* POST /plugin-update/unignore → POST /api/v1/plugin-updates/unignore {setVersion}
|
|
19
|
+
* GET /plugin-update/runtime → GET /api/v1/runtime-update
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
24
|
+
|
|
25
|
+
export const UPDATE_ROUTE_PREFIX = '/api/v1/huaqiu/plugin-update'
|
|
26
|
+
|
|
27
|
+
/** End-to-end budget for one forwarded request, in milliseconds. */
|
|
28
|
+
export const DEFAULT_FORWARD_TIMEOUT_MS = 30_000
|
|
29
|
+
|
|
30
|
+
export type BaseUrlResolver = () => string | undefined
|
|
31
|
+
|
|
32
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
33
|
+
const payload = JSON.stringify(body)
|
|
34
|
+
res.writeHead(status, {
|
|
35
|
+
'content-type': 'application/json; charset=utf-8',
|
|
36
|
+
'cache-control': 'no-store',
|
|
37
|
+
})
|
|
38
|
+
res.end(payload)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Split the sub-path off the prefix. Returns null on a bad shape. */
|
|
42
|
+
function parsePath(req: IncomingMessage): string | null {
|
|
43
|
+
const url = req.url ?? ''
|
|
44
|
+
const q = url.indexOf('?')
|
|
45
|
+
const pathname = q >= 0 ? url.slice(0, q) : url
|
|
46
|
+
if (!pathname.startsWith(UPDATE_ROUTE_PREFIX)) return null
|
|
47
|
+
return pathname.slice(UPDATE_ROUTE_PREFIX.length)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the request body (bounded) as UTF-8 text. */
|
|
51
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let size = 0
|
|
54
|
+
const chunks: Buffer[] = []
|
|
55
|
+
req.on('data', (chunk: Buffer) => {
|
|
56
|
+
size += chunk.length
|
|
57
|
+
if (size > 64 * 1024) {
|
|
58
|
+
reject(new Error('request body too large'))
|
|
59
|
+
req.destroy()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
chunks.push(chunk)
|
|
63
|
+
})
|
|
64
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
65
|
+
req.on('error', reject)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Map a sub-path to { method, path } on the hq-edge API. */
|
|
70
|
+
function routeFor(sub: string): { method: string; path: string } | null {
|
|
71
|
+
if (sub === '/report' || sub === '') {
|
|
72
|
+
return { method: 'GET', path: '/api/v1/plugin-updates' }
|
|
73
|
+
}
|
|
74
|
+
if (sub === '/runtime') {
|
|
75
|
+
return { method: 'GET', path: '/api/v1/runtime-update' }
|
|
76
|
+
}
|
|
77
|
+
if (sub === '/check') return { method: 'POST', path: '/api/v1/plugin-updates/check' }
|
|
78
|
+
if (sub === '/install') return { method: 'POST', path: '/api/v1/plugin-updates/install' }
|
|
79
|
+
if (sub === '/ignore') return { method: 'POST', path: '/api/v1/plugin-updates/ignore' }
|
|
80
|
+
if (sub === '/unignore') return { method: 'POST', path: '/api/v1/plugin-updates/unignore' }
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Create the prefix-route handler forwarding to hq-edge. */
|
|
85
|
+
export function createUpdateForwarder(
|
|
86
|
+
baseUrlResolver: BaseUrlResolver,
|
|
87
|
+
timeoutMs: number = DEFAULT_FORWARD_TIMEOUT_MS,
|
|
88
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
89
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
90
|
+
const sub = parsePath(req)
|
|
91
|
+
if (sub === null) {
|
|
92
|
+
sendJson(res, 404, { error: 'not found' })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const target = routeFor(sub)
|
|
96
|
+
if (target === null) {
|
|
97
|
+
sendJson(res, 404, { error: 'not found' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (req.method !== 'GET' && req.method !== 'POST' && req.method !== 'HEAD') {
|
|
101
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const base = (baseUrlResolver() ?? '').replace(/\/+$/, '')
|
|
105
|
+
if (base.length === 0) {
|
|
106
|
+
sendJson(res, 503, { error: 'hq-edge is not reachable from this plugin (no base URL)' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
let body = ''
|
|
110
|
+
if (req.method === 'POST') {
|
|
111
|
+
try {
|
|
112
|
+
body = await readBody(req)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const controller = new AbortController()
|
|
119
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
120
|
+
try {
|
|
121
|
+
const upstream = await fetch(`${base}${target.path}`, {
|
|
122
|
+
method: target.method,
|
|
123
|
+
headers: {
|
|
124
|
+
'content-type': 'application/json; charset=utf-8',
|
|
125
|
+
'user-agent': 'hq-edge-dsh-tool-plugin-update/0.5.21',
|
|
126
|
+
},
|
|
127
|
+
...(body.length > 0 ? { body } : {}),
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
})
|
|
130
|
+
const upstreamBody = await upstream.text()
|
|
131
|
+
let json: unknown = null
|
|
132
|
+
try {
|
|
133
|
+
json = JSON.parse(upstreamBody)
|
|
134
|
+
} catch {
|
|
135
|
+
json = { raw: upstreamBody.slice(0, 512) }
|
|
136
|
+
}
|
|
137
|
+
sendJson(res, upstream.status, json)
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
140
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
141
|
+
sendJson(res, 504, { error: `hq-edge request timed out after ${timeoutMs}ms` })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
sendJson(res, 502, { error: `hq-edge request failed: ${message}` })
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/README.md
DELETED