om-memory-system 4.9.0 → 4.10.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/dist/cli/web-command.js +11 -2
- package/dist/cli/web-power.d.ts +21 -3
- package/dist/cli/web-power.js +99 -15
- package/dist/services/web-server.d.ts +8 -1
- package/dist/services/web-server.js +72 -2
- package/dist/services/web-update-runtime.d.ts +3 -0
- package/dist/services/web-update-runtime.js +47 -0
- package/dist/services/web-update.d.ts +76 -0
- package/dist/services/web-update.js +157 -0
- package/dist/web/assets/index-BkqDHQCc.css +2 -0
- package/dist/web/assets/index-DWPF5lXj.js +125 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DyI5iI5F.js +0 -125
- package/dist/web/assets/index-kKl1G5gD.css +0 -2
package/dist/cli/web-command.js
CHANGED
|
@@ -123,7 +123,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
|
|
|
123
123
|
process.once("SIGTERM", stop);
|
|
124
124
|
// The page's Stop and Restart buttons reach the same process through this callback.
|
|
125
125
|
const { createPowerAction } = await import("./web-power.js");
|
|
126
|
-
|
|
126
|
+
const powerAction = createPowerAction({
|
|
127
127
|
stopServer: async () => {
|
|
128
128
|
clearInterval(keeper);
|
|
129
129
|
process.off("SIGINT", stop);
|
|
@@ -140,7 +140,16 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
|
|
|
140
140
|
baseUrl: server.getUrl(),
|
|
141
141
|
loginItem: action === "--login-item",
|
|
142
142
|
autostart: options,
|
|
143
|
-
})
|
|
143
|
+
});
|
|
144
|
+
server.setOnPowerAction(powerAction);
|
|
145
|
+
// The update button installs the npm release and restarts onto it through the launcher.
|
|
146
|
+
const { startWebUpdate } = await import("../services/web-update-runtime.js");
|
|
147
|
+
const { launcherPath } = await import("../services/runtime-record.js");
|
|
148
|
+
const { ommsDir } = await import("../services/runtime-handoff.js");
|
|
149
|
+
const { existsSync } = await import("node:fs");
|
|
150
|
+
// Every restart onto the new copy runs the launcher, the login item's included.
|
|
151
|
+
const canRestart = () => existsSync(launcherPath(ommsDir()));
|
|
152
|
+
server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart));
|
|
144
153
|
// A newer `web install` asked this web app to give up the port.
|
|
145
154
|
server.setOnStepAside(async () => {
|
|
146
155
|
clearInterval(keeper);
|
package/dist/cli/web-power.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { WebAutostartOptions } from "../services/web-autostart.js";
|
|
2
2
|
/** The two choices the page offers; the web server passes them through unchanged. */
|
|
3
3
|
export type PowerAction = "stop" | "restart";
|
|
4
|
+
/** A restart through the launcher, so the newest copy on the machine serves after an update. */
|
|
5
|
+
export type ProcessAction = PowerAction | "update";
|
|
4
6
|
interface SpawnedChild {
|
|
5
7
|
pid?: number;
|
|
6
8
|
unref(): void;
|
|
@@ -16,6 +18,7 @@ export interface PowerDeps {
|
|
|
16
18
|
stdio: "ignore";
|
|
17
19
|
cwd: string;
|
|
18
20
|
windowsHide: true;
|
|
21
|
+
env: NodeJS.ProcessEnv;
|
|
19
22
|
}) => SpawnedChild;
|
|
20
23
|
/** Restart the login item through the service manager. False when it cannot. */
|
|
21
24
|
restartLoginItem: () => boolean | Promise<boolean>;
|
|
@@ -23,14 +26,29 @@ export interface PowerDeps {
|
|
|
23
26
|
writeStartLock: (pid: number) => void | Promise<void>;
|
|
24
27
|
/** Remove the start lock when it names `pid`. */
|
|
25
28
|
removeStartLock: (pid: number) => void | Promise<void>;
|
|
26
|
-
/**
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The OMMS web app that answers at `baseUrl`, or null when none does. The
|
|
31
|
+
* version is null when this process may not read it, for example behind basic auth.
|
|
32
|
+
*/
|
|
33
|
+
readOwner: (baseUrl: string) => Promise<{
|
|
34
|
+
instance: string;
|
|
35
|
+
version: string | null;
|
|
36
|
+
} | null>;
|
|
37
|
+
/** Ask the web app at `baseUrl` to step aside for `version`. True when it agrees. */
|
|
38
|
+
stepAside: (baseUrl: string, version: string) => Promise<boolean>;
|
|
39
|
+
/** The instance id the copy reports, so the handoff knows its own copy. */
|
|
40
|
+
newInstance: () => string;
|
|
41
|
+
/** The version on disk now; an in-place upgrade changes it after this process started. */
|
|
42
|
+
version: () => string;
|
|
43
|
+
env: NodeJS.ProcessEnv;
|
|
28
44
|
sleep: (ms: number) => Promise<void>;
|
|
29
45
|
now: () => number;
|
|
30
46
|
exit: (code: number) => void;
|
|
31
47
|
/** The runtime and arguments of this process, for a detached copy. */
|
|
32
48
|
execPath: string;
|
|
33
49
|
args: string[];
|
|
50
|
+
/** The launcher at `~/.omms/bin`, or null when it is missing. */
|
|
51
|
+
launcher: string | null;
|
|
34
52
|
cwd: string;
|
|
35
53
|
pid: number;
|
|
36
54
|
log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
|
|
@@ -51,5 +69,5 @@ export declare function createPowerAction(options: {
|
|
|
51
69
|
loginItem: boolean;
|
|
52
70
|
autostart?: WebAutostartOptions;
|
|
53
71
|
deps?: Partial<PowerDeps>;
|
|
54
|
-
}): (action:
|
|
72
|
+
}): (action: ProcessAction) => Promise<string | undefined>;
|
|
55
73
|
export {};
|
package/dist/cli/web-power.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
2
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { packageVersion } from "../services/package-version.js";
|
|
6
|
+
import { ommsDir } from "../services/runtime-handoff.js";
|
|
7
|
+
import { launcherPath } from "../services/runtime-record.js";
|
|
8
|
+
import { compareVersions } from "../services/version-compare.js";
|
|
3
9
|
/** How long a fresh copy must stay up before the old process stops serving. */
|
|
4
10
|
const SPAWN_CHECK_MS = 1_000;
|
|
5
11
|
/** How long the old process waits for the copy to answer before it serves again. */
|
|
6
12
|
export const HANDOFF_WAIT_MS = 15_000;
|
|
7
13
|
const HANDOFF_POLL_MS = 250;
|
|
14
|
+
let tokenHeader = null;
|
|
15
|
+
/** The web app runs on this machine, so the local token file is enough. Read once. */
|
|
16
|
+
function localTokenHeader() {
|
|
17
|
+
tokenHeader ??= import("../services/auth-token.js").then(({ AUTH_HEADER, getOrCreateAuthToken }) => ({
|
|
18
|
+
[AUTH_HEADER]: getOrCreateAuthToken(),
|
|
19
|
+
}));
|
|
20
|
+
return tokenHeader;
|
|
21
|
+
}
|
|
8
22
|
function productionDeps(options) {
|
|
9
23
|
return {
|
|
10
24
|
spawn: nodeSpawn,
|
|
@@ -14,23 +28,55 @@ function productionDeps(options) {
|
|
|
14
28
|
nodeLockFs.replace(startLockPath(), JSON.stringify({ pid, at: Date.now() }));
|
|
15
29
|
},
|
|
16
30
|
removeStartLock: async (pid) => (await import("../services/web-ensure.js")).removeStartLockFor(pid),
|
|
17
|
-
|
|
31
|
+
readOwner: async (baseUrl) => {
|
|
32
|
+
// Health skips basic auth, so a restart can find its copy with basic auth on.
|
|
33
|
+
let instance;
|
|
34
|
+
try {
|
|
35
|
+
const health = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1_000) });
|
|
36
|
+
const body = (await health.json());
|
|
37
|
+
if (!health.ok || typeof body.instance !== "string")
|
|
38
|
+
return null;
|
|
39
|
+
instance = body.instance;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
18
44
|
try {
|
|
19
|
-
const response = await fetch(`${baseUrl}/api/
|
|
45
|
+
const response = await fetch(`${baseUrl}/api/web/status`, {
|
|
46
|
+
headers: await localTokenHeader(),
|
|
20
47
|
signal: AbortSignal.timeout(1_000),
|
|
21
48
|
});
|
|
22
49
|
const body = (await response.json());
|
|
23
|
-
|
|
50
|
+
const version = response.ok && typeof body.version === "string" ? body.version : null;
|
|
51
|
+
return { instance, version };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { instance, version: null };
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
stepAside: async (baseUrl, version) => {
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch(`${baseUrl}/api/web/step-aside`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { ...(await localTokenHeader()), "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ version }),
|
|
63
|
+
signal: AbortSignal.timeout(1_000),
|
|
64
|
+
});
|
|
65
|
+
return response.status === 202;
|
|
24
66
|
}
|
|
25
67
|
catch {
|
|
26
68
|
return false;
|
|
27
69
|
}
|
|
28
70
|
},
|
|
71
|
+
newInstance: () => randomUUID(),
|
|
72
|
+
version: () => packageVersion(),
|
|
73
|
+
env: process.env,
|
|
29
74
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
30
75
|
now: () => Date.now(),
|
|
31
76
|
exit: (code) => process.exit(code),
|
|
32
77
|
execPath: process.execPath,
|
|
33
78
|
args: process.argv.slice(1),
|
|
79
|
+
launcher: existsSync(launcherPath(ommsDir())) ? launcherPath(ommsDir()) : null,
|
|
34
80
|
cwd: homedir(),
|
|
35
81
|
pid: process.pid,
|
|
36
82
|
log: async (message, data) => (await import("../services/logger.js")).log(message, data),
|
|
@@ -49,14 +95,16 @@ export function createPowerAction(options) {
|
|
|
49
95
|
const logFailure = (code) => Promise.resolve(deps.log("Web app restart failed", { code })).catch(() => undefined);
|
|
50
96
|
let stopped = false;
|
|
51
97
|
/** Start the copy, check it, stop serving, and wait until the copy answers. */
|
|
52
|
-
async function handOff() {
|
|
98
|
+
async function handOff(args) {
|
|
53
99
|
let child;
|
|
100
|
+
const instance = deps.newInstance();
|
|
54
101
|
try {
|
|
55
|
-
child = deps.spawn(deps.execPath,
|
|
102
|
+
child = deps.spawn(deps.execPath, args, {
|
|
56
103
|
detached: true,
|
|
57
104
|
stdio: "ignore",
|
|
58
105
|
cwd: deps.cwd,
|
|
59
106
|
windowsHide: true,
|
|
107
|
+
env: { ...deps.env, OMMS_WEB_INSTANCE: instance },
|
|
60
108
|
});
|
|
61
109
|
}
|
|
62
110
|
catch {
|
|
@@ -82,12 +130,30 @@ export function createPowerAction(options) {
|
|
|
82
130
|
stopped = true;
|
|
83
131
|
}
|
|
84
132
|
// The copy waits as a non-owner and takes the port within about 7 seconds.
|
|
85
|
-
|
|
133
|
+
let began = deps.now();
|
|
134
|
+
// Each older owner is asked once; another older waiter can take the port after it.
|
|
135
|
+
const askedAside = new Set();
|
|
86
136
|
while (deps.now() - began < HANDOFF_WAIT_MS) {
|
|
87
137
|
if (exited)
|
|
88
138
|
return giveUp("copy-exit", copyPid);
|
|
89
|
-
|
|
90
|
-
|
|
139
|
+
const owner = await deps.readOwner(options.baseUrl);
|
|
140
|
+
if (owner?.instance === instance) {
|
|
141
|
+
deps.exit(0);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
// Another web app that waited for the port took it first.
|
|
145
|
+
if (owner) {
|
|
146
|
+
const ownVersion = deps.version();
|
|
147
|
+
const order = owner.version === null ? null : compareVersions(owner.version, ownVersion);
|
|
148
|
+
if (order === null || order >= 0)
|
|
149
|
+
return yieldTo(child, copyPid);
|
|
150
|
+
if (!askedAside.has(owner.instance)) {
|
|
151
|
+
askedAside.add(owner.instance);
|
|
152
|
+
// The copy needs a fresh wait once the older web app leaves.
|
|
153
|
+
if (await deps.stepAside(options.baseUrl, ownVersion))
|
|
154
|
+
began = deps.now();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
91
157
|
await deps.sleep(HANDOFF_POLL_MS);
|
|
92
158
|
}
|
|
93
159
|
if (exited)
|
|
@@ -96,7 +162,15 @@ export function createPowerAction(options) {
|
|
|
96
162
|
child.kill("SIGTERM");
|
|
97
163
|
return giveUp("handoff", copyPid);
|
|
98
164
|
}
|
|
99
|
-
/**
|
|
165
|
+
/** A web app of the same or a newer version serves, so the copy would only wait. */
|
|
166
|
+
async function yieldTo(child, copyPid) {
|
|
167
|
+
child.kill("SIGTERM");
|
|
168
|
+
await deps.removeStartLock(copyPid);
|
|
169
|
+
await logFailure("other-owner");
|
|
170
|
+
deps.exit(0);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
/** Keep or resume serving on the old process, and release the lock it wrote. Returns the code. */
|
|
100
174
|
async function giveUp(code, copyPid) {
|
|
101
175
|
if (copyPid !== undefined)
|
|
102
176
|
await deps.removeStartLock(copyPid);
|
|
@@ -104,7 +178,7 @@ export function createPowerAction(options) {
|
|
|
104
178
|
await deps.removeStartLock(deps.pid);
|
|
105
179
|
await logFailure(code);
|
|
106
180
|
if (!stopped)
|
|
107
|
-
return;
|
|
181
|
+
return code;
|
|
108
182
|
try {
|
|
109
183
|
await options.resumeServer();
|
|
110
184
|
stopped = false;
|
|
@@ -113,29 +187,39 @@ export function createPowerAction(options) {
|
|
|
113
187
|
await logFailure(error instanceof Error ? error.name : "resume-error");
|
|
114
188
|
deps.exit(1);
|
|
115
189
|
}
|
|
190
|
+
return code;
|
|
116
191
|
}
|
|
117
192
|
return async (action) => {
|
|
118
193
|
if (action === "stop") {
|
|
119
194
|
await options.stopServer();
|
|
120
195
|
deps.exit(0);
|
|
121
|
-
return;
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
// After an update the copy of this process is the old version; the launcher picks the newest.
|
|
199
|
+
if (action === "update" && !deps.launcher) {
|
|
200
|
+
await logFailure("no-launcher");
|
|
201
|
+
return "no-launcher";
|
|
122
202
|
}
|
|
203
|
+
const args = action === "update" ? [deps.launcher, "web"] : deps.args;
|
|
123
204
|
stopped = false;
|
|
124
205
|
try {
|
|
125
206
|
if (options.loginItem) {
|
|
207
|
+
// The login item runs the launcher, so the service manager restart covers an update too.
|
|
126
208
|
await options.stopServer();
|
|
127
209
|
stopped = true;
|
|
128
|
-
if (await deps.restartLoginItem())
|
|
129
|
-
|
|
210
|
+
if (await deps.restartLoginItem()) {
|
|
211
|
+
deps.exit(0);
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
130
214
|
}
|
|
131
215
|
else {
|
|
132
216
|
// Hold other hosts off until the copy owns the port.
|
|
133
217
|
await deps.writeStartLock(deps.pid);
|
|
134
218
|
}
|
|
135
|
-
await handOff();
|
|
219
|
+
return await handOff(args);
|
|
136
220
|
}
|
|
137
221
|
catch (error) {
|
|
138
|
-
|
|
222
|
+
return giveUp(error instanceof Error ? error.name : "unknown");
|
|
139
223
|
}
|
|
140
224
|
};
|
|
141
225
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebUpdate } from "./web-update.js";
|
|
1
2
|
import { WebAuth } from "./web-auth.js";
|
|
2
3
|
type NodeEventSource = {
|
|
3
4
|
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
@@ -37,6 +38,7 @@ export declare class WebServer {
|
|
|
37
38
|
private onPortsExhaustedCallback;
|
|
38
39
|
private onStepAsideCallback;
|
|
39
40
|
private onPowerActionCallback;
|
|
41
|
+
private webUpdate;
|
|
40
42
|
private powerActionRunning;
|
|
41
43
|
private stepAsideTimer;
|
|
42
44
|
private holdOffTimer;
|
|
@@ -59,9 +61,13 @@ export declare class WebServer {
|
|
|
59
61
|
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
60
62
|
setOnStepAside(callback: () => void | Promise<void>): void;
|
|
61
63
|
/** Standalone web apps register this to stop or restart from the page. */
|
|
62
|
-
setOnPowerAction(callback: (action: PowerAction) =>
|
|
64
|
+
setOnPowerAction(callback: (action: PowerAction) => unknown): void;
|
|
65
|
+
/** Standalone web apps register this to report and install npm releases. */
|
|
66
|
+
setWebUpdate(update: Pick<WebUpdate, "status" | "requestInstall">): void;
|
|
63
67
|
/** Only a local caller may stop the web app; the token alone is not enough. */
|
|
64
68
|
private handlePowerAction;
|
|
69
|
+
/** Install the npm release and restart onto it. Same guards as Restart. */
|
|
70
|
+
private handleUpdate;
|
|
65
71
|
/** API token routes. Only a caller on this machine with the local token manages tokens. */
|
|
66
72
|
private handleTokenRoute;
|
|
67
73
|
/** Profile catch-up: a preview for anyone with API access; runs only from this machine. */
|
|
@@ -77,6 +83,7 @@ export declare class WebServer {
|
|
|
77
83
|
start(): Promise<void>;
|
|
78
84
|
private _start;
|
|
79
85
|
private startHealthCheckLoop;
|
|
86
|
+
private ownerIsNewer;
|
|
80
87
|
private stopHealthCheckLoop;
|
|
81
88
|
private attemptTakeover;
|
|
82
89
|
private notifyPortsExhausted;
|
|
@@ -142,10 +142,19 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
|
|
|
142
142
|
return currentPort + 1;
|
|
143
143
|
}
|
|
144
144
|
const STEP_ASIDE_HOLD_OFF_MS = 60_000;
|
|
145
|
+
/** What a web app without an npm check reports, such as one inside a host session. */
|
|
146
|
+
const NO_UPDATE = { available: null, state: "idle", code: null, canInstall: false };
|
|
145
147
|
/** Lets the 202 reply reach the caller before the server stops. */
|
|
146
148
|
const STEP_ASIDE_REPLY_GRACE_MS = 100;
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Differs between processes, so the page can tell a restarted web app from the old one.
|
|
151
|
+
* A restart names its copy's instance, so it can tell its copy from another web app.
|
|
152
|
+
*/
|
|
153
|
+
const PROCESS_INSTANCE = /^[0-9a-f-]{36}$/.test(process.env.OMMS_WEB_INSTANCE ?? "")
|
|
154
|
+
? process.env.OMMS_WEB_INSTANCE
|
|
155
|
+
: randomUUID();
|
|
156
|
+
// Child processes, such as an OpenCode model list, must not report the same instance.
|
|
157
|
+
delete process.env.OMMS_WEB_INSTANCE;
|
|
149
158
|
export class WebServer {
|
|
150
159
|
server = null;
|
|
151
160
|
config;
|
|
@@ -156,6 +165,7 @@ export class WebServer {
|
|
|
156
165
|
onPortsExhaustedCallback = null;
|
|
157
166
|
onStepAsideCallback = null;
|
|
158
167
|
onPowerActionCallback = null;
|
|
168
|
+
webUpdate = null;
|
|
159
169
|
powerActionRunning = false;
|
|
160
170
|
stepAsideTimer = null;
|
|
161
171
|
holdOffTimer = null;
|
|
@@ -222,6 +232,10 @@ export class WebServer {
|
|
|
222
232
|
setOnPowerAction(callback) {
|
|
223
233
|
this.onPowerActionCallback = callback;
|
|
224
234
|
}
|
|
235
|
+
/** Standalone web apps register this to report and install npm releases. */
|
|
236
|
+
setWebUpdate(update) {
|
|
237
|
+
this.webUpdate = update;
|
|
238
|
+
}
|
|
225
239
|
/** Only a local caller may stop the web app; the token alone is not enough. */
|
|
226
240
|
handlePowerAction(action, remoteAddress) {
|
|
227
241
|
const record = (outcome) => log("Web server power request", { action, outcome, ownVersion: packageVersion() });
|
|
@@ -234,6 +248,12 @@ export class WebServer {
|
|
|
234
248
|
record("unsupported");
|
|
235
249
|
return this.jsonResponse({ success: false, error: "Not supported by this web app" }, 409);
|
|
236
250
|
}
|
|
251
|
+
// An update restarts through the same callback; a Stop or Restart now would cut npm off.
|
|
252
|
+
const updateState = this.webUpdate?.status().state;
|
|
253
|
+
if (updateState === "installing" || updateState === "restarting") {
|
|
254
|
+
record("refused_update_running");
|
|
255
|
+
return this.jsonResponse({ success: false, error: "An update is running" }, 409);
|
|
256
|
+
}
|
|
237
257
|
// A repeated request must not start a second stop or restart while one runs.
|
|
238
258
|
if (this.powerActionRunning) {
|
|
239
259
|
record("already_running");
|
|
@@ -255,6 +275,24 @@ export class WebServer {
|
|
|
255
275
|
}, STEP_ASIDE_REPLY_GRACE_MS).unref();
|
|
256
276
|
return this.jsonResponse({ success: true }, 202);
|
|
257
277
|
}
|
|
278
|
+
/** Install the npm release and restart onto it. Same guards as Restart. */
|
|
279
|
+
handleUpdate(remoteAddress) {
|
|
280
|
+
const record = (outcome) => log("Web server update request", { outcome, ownVersion: packageVersion() });
|
|
281
|
+
if (!isLoopbackAddress(remoteAddress)) {
|
|
282
|
+
record("refused_not_loopback");
|
|
283
|
+
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
284
|
+
}
|
|
285
|
+
if (this.powerActionRunning) {
|
|
286
|
+
record("refused_power_running");
|
|
287
|
+
return this.jsonResponse({ success: false, error: "A stop or restart is running" }, 409);
|
|
288
|
+
}
|
|
289
|
+
const reply = this.webUpdate?.requestInstall() ?? "cannot-install";
|
|
290
|
+
record(reply);
|
|
291
|
+
if (reply === "accepted")
|
|
292
|
+
return this.jsonResponse({ success: true }, 202);
|
|
293
|
+
const error = reply === "no-update" ? "No newer release" : "This web app cannot install";
|
|
294
|
+
return this.jsonResponse({ success: false, error }, 409);
|
|
295
|
+
}
|
|
258
296
|
/** API token routes. Only a caller on this machine with the local token manages tokens. */
|
|
259
297
|
async handleTokenRoute(req, path, method, remoteAddress) {
|
|
260
298
|
if (!isLoopbackAddress(remoteAddress)) {
|
|
@@ -540,8 +578,34 @@ export class WebServer {
|
|
|
540
578
|
this.stopHealthCheckLoop();
|
|
541
579
|
await this.attemptTakeover();
|
|
542
580
|
}
|
|
581
|
+
else if (this.onStepAsideCallback && (await this.ownerIsNewer())) {
|
|
582
|
+
// A standalone web app behind a newer one would serve old code after a restart.
|
|
583
|
+
this.stopHealthCheckLoop();
|
|
584
|
+
log("Web server waiter retired", { ownVersion: packageVersion() });
|
|
585
|
+
try {
|
|
586
|
+
await this.onStepAsideCallback();
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
log("Step-aside callback error", { error: String(error) });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
543
592
|
}, 5000);
|
|
544
593
|
}
|
|
594
|
+
async ownerIsNewer() {
|
|
595
|
+
try {
|
|
596
|
+
const response = await fetch(`${this.getUrl()}/api/web/status`, {
|
|
597
|
+
headers: { [AUTH_HEADER]: getOrCreateAuthToken() },
|
|
598
|
+
signal: AbortSignal.timeout(2_000),
|
|
599
|
+
});
|
|
600
|
+
if (!response.ok)
|
|
601
|
+
return false;
|
|
602
|
+
const { version } = (await response.json());
|
|
603
|
+
return typeof version === "string" && isOlderVersion(packageVersion(), version);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
545
609
|
stopHealthCheckLoop() {
|
|
546
610
|
if (this.healthCheckInterval) {
|
|
547
611
|
clearInterval(this.healthCheckInterval);
|
|
@@ -729,6 +793,8 @@ export class WebServer {
|
|
|
729
793
|
success: true,
|
|
730
794
|
status: "ok",
|
|
731
795
|
authEnabled: auth?.isEnabled() ?? false,
|
|
796
|
+
// Opaque per process; a restart finds its copy by it even behind basic auth.
|
|
797
|
+
instance: PROCESS_INSTANCE,
|
|
732
798
|
});
|
|
733
799
|
}
|
|
734
800
|
if (path === "/" || path === "/index.html" || path === "/settings") {
|
|
@@ -813,11 +879,15 @@ export class WebServer {
|
|
|
813
879
|
// Keys, tokens, and the embedder can be changed only from this machine.
|
|
814
880
|
isLocal: isLoopbackAddress(remoteAddress),
|
|
815
881
|
instance: PROCESS_INSTANCE,
|
|
882
|
+
update: this.webUpdate?.status() ?? NO_UPDATE,
|
|
816
883
|
});
|
|
817
884
|
}
|
|
818
885
|
if ((path === "/api/web/stop" || path === "/api/web/restart") && method === "POST") {
|
|
819
886
|
return this.handlePowerAction(path === "/api/web/stop" ? "stop" : "restart", remoteAddress);
|
|
820
887
|
}
|
|
888
|
+
if (path === "/api/web/update" && method === "POST") {
|
|
889
|
+
return this.handleUpdate(remoteAddress);
|
|
890
|
+
}
|
|
821
891
|
if (path === "/api/web/step-aside" && method === "POST") {
|
|
822
892
|
const body = await req.json().catch(() => null);
|
|
823
893
|
return this.handleStepAside(remoteAddress, body);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { globalCommandVersion } from "./global-version.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
import { packageVersion } from "./package-version.js";
|
|
6
|
+
import { WebUpdate } from "./web-update.js";
|
|
7
|
+
/** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
|
|
8
|
+
export function startWebUpdate(restart, canRestart) {
|
|
9
|
+
const update = new WebUpdate({
|
|
10
|
+
version: packageVersion(),
|
|
11
|
+
enabled: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1",
|
|
12
|
+
fetch,
|
|
13
|
+
setInterval: (callback, ms) => setInterval(callback, ms),
|
|
14
|
+
log,
|
|
15
|
+
execPath: process.execPath,
|
|
16
|
+
platform: process.platform,
|
|
17
|
+
exists: existsSync,
|
|
18
|
+
spawn: (command, args, options) => spawn(command, args, {
|
|
19
|
+
shell: options.shell,
|
|
20
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
}),
|
|
23
|
+
// npm installs beside this Node.js, so read that install, not the first command on PATH.
|
|
24
|
+
globalVersion: () => globalCommandVersion({ find: () => null }).version,
|
|
25
|
+
restart,
|
|
26
|
+
canRestart,
|
|
27
|
+
// Resolves once npm has stopped. `taskkill /T` also stops npm under the cmd.exe
|
|
28
|
+
// wrapper that `shell: true` starts; the child's own exit confirms it.
|
|
29
|
+
killTree: (child) => new Promise((resolve) => {
|
|
30
|
+
child.on("exit", () => resolve());
|
|
31
|
+
if (process.platform === "win32" && child.pid) {
|
|
32
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
33
|
+
stdio: "ignore",
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
}).on("error", () => child.kill("SIGKILL"));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
child.kill("SIGTERM");
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
42
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
43
|
+
now: () => Date.now(),
|
|
44
|
+
});
|
|
45
|
+
update.start();
|
|
46
|
+
return update;
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export declare const UPDATE_CHECK_INTERVAL_MS: number;
|
|
2
|
+
/** An install that takes longer is stopped, and the web app keeps serving. */
|
|
3
|
+
export declare const INSTALL_TIMEOUT_MS: number;
|
|
4
|
+
export type UpdateState = "idle" | "installing" | "restarting" | "failed";
|
|
5
|
+
/** The `update` field of `GET /api/web/status`. */
|
|
6
|
+
export interface UpdateStatus {
|
|
7
|
+
/** The newer release on npm, or null. */
|
|
8
|
+
available: string | null;
|
|
9
|
+
state: UpdateState;
|
|
10
|
+
/** The failure code while `state` is `failed`. */
|
|
11
|
+
code: string | null;
|
|
12
|
+
/** True when this web app can install the release itself. */
|
|
13
|
+
canInstall: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface WebUpdateDeps {
|
|
16
|
+
/** The running version. */
|
|
17
|
+
version: string;
|
|
18
|
+
/** False when `OMMS_DISABLE_UPDATE_CHECK` is `1`. */
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
fetch: typeof fetch;
|
|
21
|
+
setInterval: (callback: () => void, ms: number) => {
|
|
22
|
+
unref?: () => void;
|
|
23
|
+
};
|
|
24
|
+
log: (message: string, data: Record<string, unknown>) => void;
|
|
25
|
+
/** The runtime of this web app; npm must sit beside it. */
|
|
26
|
+
execPath: string;
|
|
27
|
+
platform: NodeJS.Platform;
|
|
28
|
+
exists: (path: string) => boolean;
|
|
29
|
+
spawn: (command: string, args: string[], options: {
|
|
30
|
+
shell: boolean;
|
|
31
|
+
}) => InstallChild;
|
|
32
|
+
/** The version of the global install after npm ran, or null. */
|
|
33
|
+
globalVersion: () => string | null;
|
|
34
|
+
/** False when no restart could serve the new copy, such as a missing launcher. Checked before npm runs. */
|
|
35
|
+
canRestart: () => boolean;
|
|
36
|
+
/** Stop npm and its children. On Windows the shell wrapper alone would leave npm running. */
|
|
37
|
+
killTree: (child: InstallChild) => Promise<void>;
|
|
38
|
+
/** Restart onto the newest copy. Returns a failure code when this web app keeps serving. */
|
|
39
|
+
restart: () => Promise<string | undefined>;
|
|
40
|
+
setTimeout: (callback: () => void, ms: number) => unknown;
|
|
41
|
+
clearTimeout: (handle: unknown) => void;
|
|
42
|
+
now: () => number;
|
|
43
|
+
}
|
|
44
|
+
/** The parts of a child process the install runner uses. */
|
|
45
|
+
export interface InstallChild {
|
|
46
|
+
pid?: number;
|
|
47
|
+
stderr: {
|
|
48
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
49
|
+
} | null;
|
|
50
|
+
on(event: "error", listener: () => void): unknown;
|
|
51
|
+
on(event: "exit", listener: (code: number | null) => void): unknown;
|
|
52
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
53
|
+
}
|
|
54
|
+
export type InstallRequest = "accepted" | "no-update" | "cannot-install";
|
|
55
|
+
/** A failure code from npm's error stream. The text itself never leaves this function. */
|
|
56
|
+
export declare function npmFailureCode(errorText: string): string;
|
|
57
|
+
export declare class WebUpdate {
|
|
58
|
+
private readonly deps;
|
|
59
|
+
private available;
|
|
60
|
+
private state;
|
|
61
|
+
private code;
|
|
62
|
+
private started;
|
|
63
|
+
constructor(deps: WebUpdateDeps);
|
|
64
|
+
/** Check now, then every 6 hours. A second call does nothing. */
|
|
65
|
+
start(): void;
|
|
66
|
+
/** Read npm `latest`. A failed read keeps the last result. */
|
|
67
|
+
check(): Promise<void>;
|
|
68
|
+
/** The npm that installs into the prefix the launcher reads. */
|
|
69
|
+
private npmPath;
|
|
70
|
+
status(): UpdateStatus;
|
|
71
|
+
private canInstall;
|
|
72
|
+
/** Start the install and the restart. A request while one runs joins it. */
|
|
73
|
+
requestInstall(): InstallRequest;
|
|
74
|
+
private install;
|
|
75
|
+
private runNpm;
|
|
76
|
+
}
|