dsh-update-plugin 0.1.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/lib/index.js ADDED
@@ -0,0 +1,366 @@
1
+ // dsh-update-plugin host half.
2
+ //
3
+ // Registers loopback-only endpoints next to the DSH web server:
4
+ //
5
+ // GET /api/dsh-update-plugin/status current/target version + job state
6
+ // POST /api/dsh-update-plugin/update start an update job
7
+ // GET /api/dsh-update-plugin/config read channel/minAge
8
+ // POST /api/dsh-update-plugin/config write channel/minAge
9
+ // GET /api/dsh-update-plugin/backups list backups
10
+ // POST /api/dsh-update-plugin/rollback start a rollback job
11
+ //
12
+ // The real work lives in update-core.js.
13
+
14
+ import {
15
+ checkStatus,
16
+ listBackups,
17
+ readConfig,
18
+ rollbackBackup,
19
+ runUpdate,
20
+ runtimeFromProcess,
21
+ writeConfig,
22
+ } from "./update-core.js";
23
+
24
+ export const name = "dsh-update-plugin";
25
+ export const inject = ["webServer"];
26
+
27
+ const HEADER = "x-dsh-update-plugin";
28
+ const STATUS_PATH = "/api/dsh-update-plugin/status";
29
+ const UPDATE_PATH = "/api/dsh-update-plugin/update";
30
+ const CONFIG_PATH = "/api/dsh-update-plugin/config";
31
+ const BACKUPS_PATH = "/api/dsh-update-plugin/backups";
32
+ const ROLLBACK_PATH = "/api/dsh-update-plugin/rollback";
33
+ const LOG_LIMIT = 200;
34
+
35
+ function header(request, key) {
36
+ const value = request.headers?.[key];
37
+ return Array.isArray(value) ? value[0] : value;
38
+ }
39
+
40
+ function isLoopbackAddress(value) {
41
+ const address = String(value || "").toLowerCase().replace(/^\[|\]$/g, "");
42
+ return (
43
+ address === "localhost" ||
44
+ address === "localhost." ||
45
+ address === "::1" ||
46
+ address.startsWith("127.") ||
47
+ address.startsWith("::ffff:127.")
48
+ );
49
+ }
50
+
51
+ function isTrustedRequest(request) {
52
+ if (header(request, HEADER) !== "1") return false;
53
+ if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
54
+ const site = header(request, "sec-fetch-site");
55
+ if (site !== undefined && site !== "same-origin") return false;
56
+ const origin = header(request, "origin");
57
+ const host = header(request, "host");
58
+ if (!origin || !host) return false;
59
+ try {
60
+ const url = new URL(origin);
61
+ return (url.protocol === "http:" || url.protocol === "https:") && isLoopbackAddress(url.hostname) && url.host === host;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function publicError(error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ if (/\/Users\/|\/home\/|\/root\/|\/private\/|[A-Za-z]:\\/.test(message)) {
70
+ return "更新失败,请查看 DSH 日志或使用终端命令。";
71
+ }
72
+ return message || "更新失败。";
73
+ }
74
+
75
+ function json(response, statusCode, value) {
76
+ response.writeHead(statusCode, {
77
+ "content-type": "application/json; charset=utf-8",
78
+ "cache-control": "no-store",
79
+ });
80
+ response.end(JSON.stringify(value));
81
+ }
82
+
83
+ function readJsonBody(request, limit = 1 << 20) {
84
+ return new Promise((resolvePromise, rejectPromise) => {
85
+ let size = 0;
86
+ const chunks = [];
87
+ request.on("data", (chunk) => {
88
+ size += chunk.length;
89
+ if (size > limit) {
90
+ rejectPromise(new Error("body too large"));
91
+ request.destroy?.();
92
+ return;
93
+ }
94
+ chunks.push(chunk);
95
+ });
96
+ request.on("end", () => {
97
+ try {
98
+ const text = Buffer.concat(chunks).toString("utf8");
99
+ resolvePromise(text ? JSON.parse(text) : {});
100
+ } catch (error) {
101
+ rejectPromise(error);
102
+ }
103
+ });
104
+ request.on("error", rejectPromise);
105
+ });
106
+ }
107
+
108
+ const job = {
109
+ running: false,
110
+ kind: null,
111
+ phase: "idle",
112
+ logs: [],
113
+ error: null,
114
+ result: null,
115
+ status: null,
116
+ startedAt: 0,
117
+ finishedAt: 0,
118
+ restartRequired: false,
119
+ };
120
+
121
+ function pushLog(line) {
122
+ job.logs.push(String(line));
123
+ if (job.logs.length > LOG_LIMIT) job.logs.splice(0, job.logs.length - LOG_LIMIT);
124
+ }
125
+
126
+ function snapshot() {
127
+ return {
128
+ ...(job.status || {}),
129
+ running: job.running,
130
+ kind: job.kind,
131
+ phase: job.phase,
132
+ logs: job.logs.slice(-40),
133
+ error: job.error,
134
+ result: job.result,
135
+ restartRequired: job.restartRequired,
136
+ };
137
+ }
138
+
139
+ async function handleStatus(runtime, refresh, response) {
140
+ if (job.running) {
141
+ json(response, 200, snapshot());
142
+ return;
143
+ }
144
+ if (job.finishedAt > 0 && !refresh) {
145
+ json(response, 200, snapshot());
146
+ return;
147
+ }
148
+ if (refresh) {
149
+ job.logs = [];
150
+ job.error = null;
151
+ job.result = null;
152
+ job.finishedAt = 0;
153
+ job.restartRequired = false;
154
+ job.kind = null;
155
+ job.phase = "idle";
156
+ }
157
+ const status = await checkStatus(runtime, { onLine: () => {} });
158
+ job.status = status;
159
+ json(response, 200, {
160
+ ...status,
161
+ running: false,
162
+ kind: null,
163
+ phase: "idle",
164
+ logs: [],
165
+ error: null,
166
+ result: null,
167
+ restartRequired: false,
168
+ });
169
+ }
170
+
171
+ function startJob(kind, runner, ctx) {
172
+ job.running = true;
173
+ job.kind = kind;
174
+ job.phase = kind === "rollback" ? "rollback" : "checking";
175
+ job.logs = [];
176
+ job.error = null;
177
+ job.result = null;
178
+ job.startedAt = Date.now();
179
+ job.finishedAt = 0;
180
+ job.restartRequired = false;
181
+
182
+ const onPhase = (phase) => {
183
+ job.phase = phase;
184
+ };
185
+ const onLog = (line) => {
186
+ pushLog(line);
187
+ try {
188
+ ctx?.logger?.info?.(`dsh-update-plugin: ${line}`);
189
+ } catch {
190
+ // logging must never break the job
191
+ }
192
+ };
193
+
194
+ void (async () => {
195
+ try {
196
+ const result = await runner(onPhase, onLog);
197
+ job.result = result;
198
+ if (kind === "update") {
199
+ job.status = {
200
+ ...(job.status || {}),
201
+ currentVersion: result.cliUpdated ? result.targetVersion : result.currentVersion,
202
+ targetVersion: result.targetVersion,
203
+ updateAvailable: false,
204
+ };
205
+ }
206
+ if (result.ok) {
207
+ job.phase = "done";
208
+ job.restartRequired = true;
209
+ } else {
210
+ job.phase = "error";
211
+ job.error = result.errors?.join("; ") || "更新失败。";
212
+ }
213
+ } catch (error) {
214
+ job.phase = "error";
215
+ job.error = publicError(error);
216
+ onLog(`error: ${job.error}`);
217
+ } finally {
218
+ job.running = false;
219
+ job.finishedAt = Date.now();
220
+ }
221
+ })();
222
+ }
223
+
224
+ function handleUpdate(runtime, request, response, ctx) {
225
+ if (!isTrustedRequest(request)) {
226
+ json(response, 403, { error: "forbidden" });
227
+ return;
228
+ }
229
+ if (job.running) {
230
+ json(response, 409, { error: "job already running" });
231
+ return;
232
+ }
233
+ startJob("update", (onPhase, onLog) => runUpdate(runtime, { onPhase, onLog }), ctx);
234
+ json(response, 202, { started: true, kind: "update" });
235
+ }
236
+
237
+ async function handleRollback(runtime, request, response, ctx) {
238
+ if (!isTrustedRequest(request)) {
239
+ json(response, 403, { error: "forbidden" });
240
+ return;
241
+ }
242
+ if (job.running) {
243
+ json(response, 409, { error: "job already running" });
244
+ return;
245
+ }
246
+ let body = {};
247
+ try {
248
+ body = await readJsonBody(request);
249
+ } catch (error) {
250
+ json(response, 400, { error: publicError(error) });
251
+ return;
252
+ }
253
+ let backupId = typeof body.id === "string" ? body.id : "";
254
+ if (!backupId) {
255
+ const backups = await listBackups(runtime.dshHome).catch(() => []);
256
+ backupId = backups[0]?.id || "";
257
+ }
258
+ if (!backupId) {
259
+ json(response, 404, { error: "no backup found" });
260
+ return;
261
+ }
262
+ startJob("rollback", (_onPhase, onLog) => rollbackBackup(runtime, backupId, { onLine: onLog }), ctx);
263
+ json(response, 202, { started: true, kind: "rollback", id: backupId });
264
+ }
265
+
266
+ async function handleConfig(runtime, request, response) {
267
+ try {
268
+ if (request.method === "GET" || request.method === "HEAD") {
269
+ json(response, 200, await readConfig(runtime.dshHome));
270
+ return;
271
+ }
272
+ if (request.method !== "POST") {
273
+ json(response, 405, { error: "method not allowed" });
274
+ return;
275
+ }
276
+ if (!isTrustedRequest(request)) {
277
+ json(response, 403, { error: "forbidden" });
278
+ return;
279
+ }
280
+ const body = await readJsonBody(request);
281
+ json(response, 200, await writeConfig(runtime.dshHome, body));
282
+ } catch (error) {
283
+ json(response, 500, { error: publicError(error) });
284
+ }
285
+ }
286
+
287
+ async function handleBackups(runtime, request, response) {
288
+ try {
289
+ if (request.method !== "GET" && request.method !== "HEAD") {
290
+ json(response, 405, { error: "method not allowed" });
291
+ return;
292
+ }
293
+ json(response, 200, { backups: await listBackups(runtime.dshHome) });
294
+ } catch (error) {
295
+ json(response, 500, { error: publicError(error) });
296
+ }
297
+ }
298
+
299
+ export function apply(ctx) {
300
+ const runtime = runtimeFromProcess();
301
+ try {
302
+ ctx?.logger?.info?.(`dsh-update-plugin loaded (profile: ${runtime.profileName})`);
303
+ } catch {
304
+ // ignore logging failures
305
+ }
306
+
307
+ ctx.effect(
308
+ () =>
309
+ ctx.webServer.register({
310
+ kind: "exact",
311
+ path: STATUS_PATH,
312
+ handler: async (request, response) => {
313
+ try {
314
+ const url = new URL(request.url || "/", "http://localhost");
315
+ const refresh = url.searchParams.get("refresh") === "1";
316
+ await handleStatus(runtime, refresh, response);
317
+ } catch (error) {
318
+ json(response, 500, { error: publicError(error) });
319
+ }
320
+ },
321
+ }),
322
+ "dsh-update-plugin: status endpoint",
323
+ );
324
+
325
+ ctx.effect(
326
+ () =>
327
+ ctx.webServer.register({
328
+ kind: "exact",
329
+ path: UPDATE_PATH,
330
+ handler: (request, response) => handleUpdate(runtime, request, response, ctx),
331
+ }),
332
+ "dsh-update-plugin: update endpoint",
333
+ );
334
+
335
+ ctx.effect(
336
+ () =>
337
+ ctx.webServer.register({
338
+ kind: "exact",
339
+ path: CONFIG_PATH,
340
+ handler: (request, response) => handleConfig(runtime, request, response),
341
+ }),
342
+ "dsh-update-plugin: config endpoint",
343
+ );
344
+
345
+ ctx.effect(
346
+ () =>
347
+ ctx.webServer.register({
348
+ kind: "exact",
349
+ path: BACKUPS_PATH,
350
+ handler: (request, response) => handleBackups(runtime, request, response),
351
+ }),
352
+ "dsh-update-plugin: backups endpoint",
353
+ );
354
+
355
+ ctx.effect(
356
+ () =>
357
+ ctx.webServer.register({
358
+ kind: "exact",
359
+ path: ROLLBACK_PATH,
360
+ handler: (request, response) => {
361
+ void handleRollback(runtime, request, response, ctx);
362
+ },
363
+ }),
364
+ "dsh-update-plugin: rollback endpoint",
365
+ );
366
+ }