om-memory-system 3.6.0-next.38 → 3.6.0-next.39
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/dist/cli/web-command.d.ts +8 -1
- package/dist/cli/web-command.js +119 -3
- package/dist/services/version-compare.d.ts +8 -0
- package/dist/services/version-compare.js +58 -0
- package/dist/services/web-api-auth.d.ts +2 -0
- package/dist/services/web-api-auth.js +10 -0
- package/dist/services/web-server.d.ts +9 -0
- package/dist/services/web-server.js +79 -4
- package/package.json +1 -1
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { WebAutostartOptions } from "../services/web-autostart.js";
|
|
2
|
+
/** Test seams for the handover in `web install`. */
|
|
3
|
+
export interface WebHandoverDeps {
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
/** The version this command runs as. */
|
|
7
|
+
version?: string;
|
|
8
|
+
}
|
|
2
9
|
/** Run the standalone web app or manage its per-user login item. */
|
|
3
|
-
export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean
|
|
10
|
+
export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean>, handover?: WebHandoverDeps): Promise<number>;
|
package/dist/cli/web-command.js
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
+
const POLL_MS = 250;
|
|
3
|
+
const POLL_LIMIT = 40;
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
5
|
+
/** The version an OMMS web app reports on `url`, or null when none answers with one. */
|
|
6
|
+
async function readWebVersion(fetchFn, url, headers) {
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetchFn(`${url}/api/settings/version`, {
|
|
9
|
+
headers,
|
|
10
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
return null;
|
|
14
|
+
const body = (await response.json());
|
|
15
|
+
return typeof body.running === "string" ? body.running : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function portAnswers(fetchFn, url) {
|
|
22
|
+
try {
|
|
23
|
+
await fetchFn(`${url}/api/health`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
|
|
31
|
+
async function negotiateOwner(ctx) {
|
|
32
|
+
const { compareVersions } = await import("../services/version-compare.js");
|
|
33
|
+
const owner = await readWebVersion(ctx.fetchFn, ctx.url, ctx.headers);
|
|
34
|
+
const order = owner === null ? null : compareVersions(owner, ctx.version);
|
|
35
|
+
// A version that cannot be placed is never stopped.
|
|
36
|
+
if (owner === null || order === null)
|
|
37
|
+
return { kind: "none" };
|
|
38
|
+
if (order === 0)
|
|
39
|
+
return { kind: "same", owner };
|
|
40
|
+
if (order > 0)
|
|
41
|
+
return { kind: "newer", owner };
|
|
42
|
+
try {
|
|
43
|
+
const reply = await ctx.fetchFn(`${ctx.url}/api/web/step-aside`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { ...ctx.headers, "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ version: ctx.version }),
|
|
47
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
48
|
+
});
|
|
49
|
+
if (reply.status !== 202)
|
|
50
|
+
return { kind: "stuck", owner };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { kind: "stuck", owner };
|
|
54
|
+
}
|
|
55
|
+
// If the old app is slow to stop, the login item still takes the port later.
|
|
56
|
+
for (let i = 0; i < POLL_LIMIT && (await portAnswers(ctx.fetchFn, ctx.url)); i++) {
|
|
57
|
+
await ctx.sleep(POLL_MS);
|
|
58
|
+
}
|
|
59
|
+
return { kind: "handed" };
|
|
60
|
+
}
|
|
61
|
+
/** Print which version serves the port after `web install`. */
|
|
62
|
+
async function announceHandover(ctx, handover) {
|
|
63
|
+
const { url, port, version } = ctx;
|
|
64
|
+
if (handover.kind === "stuck") {
|
|
65
|
+
console.log(`OMMS ${handover.owner} holds port ${port} and cannot hand it over. Stop the web app (Ctrl+C in its terminal, or quit the session that runs it), then run om-memory-system web install again.`);
|
|
66
|
+
}
|
|
67
|
+
else if (handover.kind === "newer") {
|
|
68
|
+
console.log(`OMMS ${handover.owner} already serves port ${port}. It is newer than this command (${version}). Update the global command: npm i -g om-memory-system`);
|
|
69
|
+
}
|
|
70
|
+
else if (handover.kind === "same") {
|
|
71
|
+
console.log(`OMMS web app: ${url} (version ${handover.owner})`);
|
|
72
|
+
}
|
|
73
|
+
else if (handover.kind === "none") {
|
|
74
|
+
console.log(`OMMS web app: ${url}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
let serving = null;
|
|
78
|
+
for (let i = 0; i < POLL_LIMIT && !serving; i++) {
|
|
79
|
+
serving = await readWebVersion(ctx.fetchFn, url, ctx.headers);
|
|
80
|
+
if (!serving)
|
|
81
|
+
await ctx.sleep(POLL_MS);
|
|
82
|
+
}
|
|
83
|
+
if (!serving)
|
|
84
|
+
console.log(`OMMS web app: ${url}`);
|
|
85
|
+
else if (serving === version)
|
|
86
|
+
console.log(`OMMS web app: ${url} (version ${serving})`);
|
|
87
|
+
else
|
|
88
|
+
console.log(`OMMS web app: ${url} (version ${serving}). This is not the version of this command (${version}).`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
2
91
|
/** Run the standalone web app or manage its per-user login item. */
|
|
3
|
-
export async function runWebCommand(args, options = {}, online) {
|
|
92
|
+
export async function runWebCommand(args, options = {}, online, handover = {}) {
|
|
4
93
|
const home = options.home ?? homedir();
|
|
5
94
|
const config = await import("../config.js");
|
|
6
95
|
config.initConfig(home);
|
|
@@ -21,12 +110,33 @@ export async function runWebCommand(args, options = {}, online) {
|
|
|
21
110
|
const { readGlobalConfigRevision, writeGlobalConfigKeys } = await import("../services/global-config-writer.js");
|
|
22
111
|
await writeGlobalConfigKeys({ webServerAutoStart: action === "install" }, readGlobalConfigRevision());
|
|
23
112
|
config.initConfig(home);
|
|
113
|
+
let context;
|
|
114
|
+
let outcome = { kind: "none" };
|
|
115
|
+
if (action === "install") {
|
|
116
|
+
const [{ getOrCreateAuthToken, AUTH_HEADER }, { packageVersion }] = await Promise.all([
|
|
117
|
+
import("../services/auth-token.js"),
|
|
118
|
+
import("../services/package-version.js"),
|
|
119
|
+
]);
|
|
120
|
+
const apiToken = config.CONFIG.webServerApiToken;
|
|
121
|
+
context = {
|
|
122
|
+
fetchFn: handover.fetch ?? fetch,
|
|
123
|
+
sleep: handover.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
124
|
+
url,
|
|
125
|
+
port: config.CONFIG.webServerPort,
|
|
126
|
+
headers: {
|
|
127
|
+
[AUTH_HEADER]: getOrCreateAuthToken(),
|
|
128
|
+
...(apiToken ? { authorization: `Bearer ${apiToken}` } : {}),
|
|
129
|
+
},
|
|
130
|
+
version: handover.version ?? packageVersion(),
|
|
131
|
+
};
|
|
132
|
+
outcome = await negotiateOwner(context);
|
|
133
|
+
}
|
|
24
134
|
const result = action === "install"
|
|
25
135
|
? installWebAutostart({ ...options, start: true })
|
|
26
136
|
: removeWebAutostart({ ...options, start: true });
|
|
27
137
|
console.log(`OMMS login item: ${result.state}`);
|
|
28
|
-
if (
|
|
29
|
-
|
|
138
|
+
if (context && result.state === "installed")
|
|
139
|
+
await announceHandover(context, outcome);
|
|
30
140
|
return action === "install" && result.state !== "installed" ? 1 : 0;
|
|
31
141
|
}
|
|
32
142
|
if (action === "status") {
|
|
@@ -72,5 +182,11 @@ export async function runWebCommand(args, options = {}, online) {
|
|
|
72
182
|
};
|
|
73
183
|
process.once("SIGINT", stop);
|
|
74
184
|
process.once("SIGTERM", stop);
|
|
185
|
+
// A newer `web install` asked this web app to give up the port.
|
|
186
|
+
server.setOnStepAside(async () => {
|
|
187
|
+
clearInterval(keeper);
|
|
188
|
+
await server.stop();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
});
|
|
75
191
|
return 0;
|
|
76
192
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two versions in SemVer order. Returns a negative number when `a` is older,
|
|
3
|
+
* a positive number when `a` is newer, `0` when equal, and `null` when either
|
|
4
|
+
* version cannot be parsed, such as `unknown`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function compareVersions(a: string, b: string): number | null;
|
|
7
|
+
/** True only when `a` is provably older than `b`. An unplaceable version is never older. */
|
|
8
|
+
export declare function isOlderVersion(a: string, b: string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
2
|
+
function parse(version) {
|
|
3
|
+
const match = SEMVER.exec(version.trim());
|
|
4
|
+
if (!match)
|
|
5
|
+
return null;
|
|
6
|
+
return {
|
|
7
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
8
|
+
pre: match[4] ? match[4].split(".") : [],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function comparePart(a, b) {
|
|
12
|
+
const aNumeric = /^\d+$/.test(a);
|
|
13
|
+
const bNumeric = /^\d+$/.test(b);
|
|
14
|
+
if (aNumeric && bNumeric)
|
|
15
|
+
return Math.sign(Number(a) - Number(b));
|
|
16
|
+
// A numeric part is older than a text part.
|
|
17
|
+
if (aNumeric)
|
|
18
|
+
return -1;
|
|
19
|
+
if (bNumeric)
|
|
20
|
+
return 1;
|
|
21
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Compare two versions in SemVer order. Returns a negative number when `a` is older,
|
|
25
|
+
* a positive number when `a` is newer, `0` when equal, and `null` when either
|
|
26
|
+
* version cannot be parsed, such as `unknown`.
|
|
27
|
+
*/
|
|
28
|
+
export function compareVersions(a, b) {
|
|
29
|
+
const left = parse(a);
|
|
30
|
+
const right = parse(b);
|
|
31
|
+
if (!left || !right)
|
|
32
|
+
return null;
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
const diff = left.core[i] - right.core[i];
|
|
35
|
+
if (diff)
|
|
36
|
+
return Math.sign(diff);
|
|
37
|
+
}
|
|
38
|
+
// A release is newer than any of its prereleases.
|
|
39
|
+
if (!left.pre.length || !right.pre.length)
|
|
40
|
+
return right.pre.length - left.pre.length;
|
|
41
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
42
|
+
const l = left.pre[i];
|
|
43
|
+
const r = right.pre[i];
|
|
44
|
+
if (l === undefined)
|
|
45
|
+
return -1;
|
|
46
|
+
if (r === undefined)
|
|
47
|
+
return 1;
|
|
48
|
+
const diff = comparePart(l, r);
|
|
49
|
+
if (diff)
|
|
50
|
+
return diff;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
/** True only when `a` is provably older than `b`. An unplaceable version is never older. */
|
|
55
|
+
export function isOlderVersion(a, b) {
|
|
56
|
+
const order = compareVersions(a, b);
|
|
57
|
+
return order !== null && order < 0;
|
|
58
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare function isLoopbackHost(host: string): boolean;
|
|
2
|
+
/** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
|
|
3
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
2
4
|
/** A URL needs brackets around an IPv6 literal such as ::1. */
|
|
3
5
|
export declare function webServerUrl(host: string, port: number): string;
|
|
4
6
|
export declare function assertWebServerNetworkAuth(host: string, apiToken?: string, basicAuthEnabled?: boolean): void;
|
|
@@ -3,6 +3,16 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0
|
|
|
3
3
|
export function isLoopbackHost(host) {
|
|
4
4
|
return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
|
|
5
5
|
}
|
|
6
|
+
/** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
|
|
7
|
+
export function isLoopbackAddress(address) {
|
|
8
|
+
if (!address)
|
|
9
|
+
return false;
|
|
10
|
+
const value = address
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/^::ffff:/, "");
|
|
14
|
+
return value === "::1" || /^127(?:\.\d{1,3}){3}$/.test(value);
|
|
15
|
+
}
|
|
6
16
|
/** A URL needs brackets around an IPv6 literal such as ::1. */
|
|
7
17
|
export function webServerUrl(host, port) {
|
|
8
18
|
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
@@ -23,6 +23,8 @@ interface WebServerConfig {
|
|
|
23
23
|
enabled: boolean;
|
|
24
24
|
auth?: WebAuth;
|
|
25
25
|
apiToken?: string;
|
|
26
|
+
/** How long a stepped-aside server waits before it may take the port back. */
|
|
27
|
+
stepAsideHoldOffMs?: number;
|
|
26
28
|
}
|
|
27
29
|
export declare class WebServer {
|
|
28
30
|
private server;
|
|
@@ -32,6 +34,9 @@ export declare class WebServer {
|
|
|
32
34
|
private healthCheckInterval;
|
|
33
35
|
private onTakeoverCallback;
|
|
34
36
|
private onPortsExhaustedCallback;
|
|
37
|
+
private onStepAsideCallback;
|
|
38
|
+
private stepAsideTimer;
|
|
39
|
+
private holdOffTimer;
|
|
35
40
|
private portsExhaustedNotified;
|
|
36
41
|
private takeoverFailures;
|
|
37
42
|
private readonly maxFallbackPort;
|
|
@@ -46,6 +51,10 @@ export declare class WebServer {
|
|
|
46
51
|
constructor(config: WebServerConfig);
|
|
47
52
|
setOnTakeoverCallback(callback: () => Promise<void>): void;
|
|
48
53
|
setOnPortsExhaustedCallback(callback: () => void): void;
|
|
54
|
+
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
55
|
+
setOnStepAside(callback: () => void | Promise<void>): void;
|
|
56
|
+
private scheduleStepAside;
|
|
57
|
+
private handleStepAside;
|
|
49
58
|
start(): Promise<void>;
|
|
50
59
|
private _start;
|
|
51
60
|
private startHealthCheckLoop;
|
|
@@ -5,7 +5,9 @@ import { join, dirname, extname, normalize, sep } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { log } from "./logger.js";
|
|
7
7
|
import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js";
|
|
8
|
-
import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
|
|
8
|
+
import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackAddress, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
|
|
9
|
+
import { packageVersion } from "./package-version.js";
|
|
10
|
+
import { isOlderVersion } from "./version-compare.js";
|
|
9
11
|
import { getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js";
|
|
10
12
|
import { WebAuth } from "./web-auth.js";
|
|
11
13
|
import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
|
|
@@ -34,7 +36,7 @@ function serveFetch(opts) {
|
|
|
34
36
|
const bunHandle = globalThis.Bun.serve({
|
|
35
37
|
port: opts.port,
|
|
36
38
|
hostname: opts.hostname,
|
|
37
|
-
fetch: opts.fetch,
|
|
39
|
+
fetch: (req, server) => opts.fetch(req, server.requestIP(req)?.address),
|
|
38
40
|
});
|
|
39
41
|
return Promise.resolve({ stop: () => bunHandle.stop() });
|
|
40
42
|
}
|
|
@@ -67,7 +69,7 @@ function serveFetch(opts) {
|
|
|
67
69
|
signal: abortController.signal,
|
|
68
70
|
...(hasBody ? { duplex: "half" } : {}),
|
|
69
71
|
});
|
|
70
|
-
const webRes = await opts.fetch(webReq);
|
|
72
|
+
const webRes = await opts.fetch(webReq, req.socket.remoteAddress);
|
|
71
73
|
if (destroyed)
|
|
72
74
|
return;
|
|
73
75
|
res.statusCode = webRes.status;
|
|
@@ -138,6 +140,9 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
|
|
|
138
140
|
}
|
|
139
141
|
return currentPort + 1;
|
|
140
142
|
}
|
|
143
|
+
const STEP_ASIDE_HOLD_OFF_MS = 60_000;
|
|
144
|
+
/** Lets the 202 reply reach the caller before the server stops. */
|
|
145
|
+
const STEP_ASIDE_REPLY_GRACE_MS = 100;
|
|
141
146
|
export class WebServer {
|
|
142
147
|
server = null;
|
|
143
148
|
config;
|
|
@@ -146,6 +151,9 @@ export class WebServer {
|
|
|
146
151
|
healthCheckInterval = null;
|
|
147
152
|
onTakeoverCallback = null;
|
|
148
153
|
onPortsExhaustedCallback = null;
|
|
154
|
+
onStepAsideCallback = null;
|
|
155
|
+
stepAsideTimer = null;
|
|
156
|
+
holdOffTimer = null;
|
|
149
157
|
portsExhaustedNotified = false;
|
|
150
158
|
takeoverFailures = 0;
|
|
151
159
|
maxFallbackPort;
|
|
@@ -199,6 +207,57 @@ export class WebServer {
|
|
|
199
207
|
setOnPortsExhaustedCallback(callback) {
|
|
200
208
|
this.onPortsExhaustedCallback = callback;
|
|
201
209
|
}
|
|
210
|
+
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
211
|
+
setOnStepAside(callback) {
|
|
212
|
+
this.onStepAsideCallback = callback;
|
|
213
|
+
}
|
|
214
|
+
scheduleStepAside() {
|
|
215
|
+
if (this.stepAsideTimer)
|
|
216
|
+
return;
|
|
217
|
+
this.stepAsideTimer = setTimeout(async () => {
|
|
218
|
+
this.stepAsideTimer = null;
|
|
219
|
+
if (this.onStepAsideCallback) {
|
|
220
|
+
try {
|
|
221
|
+
await this.onStepAsideCallback();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
log("Step-aside callback error", { error: String(error) });
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Inside a host session: stop serving, keep the session alive, and let the
|
|
229
|
+
// newer web app bind the port before the takeover loop can run again.
|
|
230
|
+
await this.stop();
|
|
231
|
+
this.startPromise = null;
|
|
232
|
+
this.holdOffTimer = setTimeout(() => {
|
|
233
|
+
this.holdOffTimer = null;
|
|
234
|
+
this.startHealthCheckLoop();
|
|
235
|
+
}, this.config.stepAsideHoldOffMs ?? STEP_ASIDE_HOLD_OFF_MS);
|
|
236
|
+
this.holdOffTimer.unref();
|
|
237
|
+
}, STEP_ASIDE_REPLY_GRACE_MS);
|
|
238
|
+
this.stepAsideTimer.unref();
|
|
239
|
+
}
|
|
240
|
+
handleStepAside(remoteAddress, body) {
|
|
241
|
+
const callerVersion = body && typeof body.version === "string"
|
|
242
|
+
? body.version.slice(0, 40)
|
|
243
|
+
: "";
|
|
244
|
+
const record = (outcome) => log("Web server step-aside request", {
|
|
245
|
+
outcome,
|
|
246
|
+
ownVersion: packageVersion(),
|
|
247
|
+
callerVersion,
|
|
248
|
+
});
|
|
249
|
+
if (!isLoopbackAddress(remoteAddress)) {
|
|
250
|
+
record("refused_auth");
|
|
251
|
+
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
252
|
+
}
|
|
253
|
+
if (!callerVersion || !isOlderVersion(packageVersion(), callerVersion)) {
|
|
254
|
+
record("refused_not_newer");
|
|
255
|
+
return this.jsonResponse({ success: false, error: "Caller is not newer" }, 409);
|
|
256
|
+
}
|
|
257
|
+
record("stepped_aside");
|
|
258
|
+
this.scheduleStepAside();
|
|
259
|
+
return this.jsonResponse({ success: true }, 202);
|
|
260
|
+
}
|
|
202
261
|
async start() {
|
|
203
262
|
if (this.startPromise) {
|
|
204
263
|
return this.startPromise;
|
|
@@ -334,6 +393,11 @@ export class WebServer {
|
|
|
334
393
|
}
|
|
335
394
|
async stop() {
|
|
336
395
|
this.stopHealthCheckLoop();
|
|
396
|
+
for (const timer of [this.stepAsideTimer, this.holdOffTimer])
|
|
397
|
+
if (timer)
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
this.stepAsideTimer = null;
|
|
400
|
+
this.holdOffTimer = null;
|
|
337
401
|
if (!this.isOwner || !this.server) {
|
|
338
402
|
return;
|
|
339
403
|
}
|
|
@@ -380,7 +444,7 @@ export class WebServer {
|
|
|
380
444
|
}
|
|
381
445
|
}
|
|
382
446
|
// --- HTTP request handling ---
|
|
383
|
-
async handleRequest(req) {
|
|
447
|
+
async handleRequest(req, remoteAddress) {
|
|
384
448
|
const url = new URL(req.url);
|
|
385
449
|
const path = url.pathname;
|
|
386
450
|
const method = req.method;
|
|
@@ -406,6 +470,13 @@ export class WebServer {
|
|
|
406
470
|
? authorizeApiRequest(req, this.config.apiToken)
|
|
407
471
|
: null;
|
|
408
472
|
if (!this.config.apiToken || configuredTokenFailure) {
|
|
473
|
+
if (path === "/api/web/step-aside") {
|
|
474
|
+
log("Web server step-aside request", {
|
|
475
|
+
outcome: "refused_auth",
|
|
476
|
+
ownVersion: packageVersion(),
|
|
477
|
+
callerVersion: "",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
409
480
|
return (configuredTokenFailure ??
|
|
410
481
|
this.jsonResponse({ success: false, error: "Unauthorized" }, 401));
|
|
411
482
|
}
|
|
@@ -471,6 +542,10 @@ export class WebServer {
|
|
|
471
542
|
? controls.resume(host, cwd)
|
|
472
543
|
: controls.runNow(host, cwd));
|
|
473
544
|
}
|
|
545
|
+
if (path === "/api/web/step-aside" && method === "POST") {
|
|
546
|
+
const body = await req.json().catch(() => null);
|
|
547
|
+
return this.handleStepAside(remoteAddress, body);
|
|
548
|
+
}
|
|
474
549
|
if (path === "/api/settings/version" && method === "GET") {
|
|
475
550
|
const [{ packageVersion }, { globalCommandVersion }] = await Promise.all([
|
|
476
551
|
import("./package-version.js"),
|