@rse/ase 0.0.6 → 0.0.8
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/dst/ase-agent-base.js +16 -25
- package/dst/ase-agent-biz.js +18 -27
- package/dst/ase-agent-dev.js +18 -27
- package/dst/ase-agent-ops.js +18 -27
- package/dst/ase-agent-prd.js +18 -27
- package/dst/ase-agent-prj.js +18 -27
- package/dst/ase-agent.js +19 -29
- package/dst/ase-config.js +375 -44
- package/dst/ase-log.js +69 -0
- package/dst/ase-service.js +376 -216
- package/dst/ase-setup.js +9 -10
- package/dst/ase.1 +51 -4
- package/dst/ase.js +48 -35
- package/package.json +9 -4
- package/README.md +0 -42
package/dst/ase-service.js
CHANGED
|
@@ -6,70 +6,26 @@
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import net from "node:net";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { parseDocument } from "yaml";
|
|
11
11
|
import Hapi from "@hapi/hapi";
|
|
12
12
|
import axios from "axios";
|
|
13
|
+
import { isMap } from "yaml";
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
import prettyMs from "pretty-ms";
|
|
16
|
+
import { Config, configSchema } from "./ase-config.js";
|
|
13
17
|
const SERVE_ENV = "ASE_SERVICE_SERVE";
|
|
18
|
+
const PORT_ENV = "ASE_SERVICE_PORT";
|
|
14
19
|
const HOST = "127.0.0.1";
|
|
15
20
|
const IDLE_MS = 30 * 60 * 1000;
|
|
16
21
|
const TICK_MS = 60 * 1000;
|
|
17
22
|
const PORT_MIN = 42000;
|
|
18
23
|
const PORT_MAX = 44000;
|
|
19
24
|
const PORT_TRIES = 20;
|
|
20
|
-
/*
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const candidate = path.join(dir, rel);
|
|
25
|
-
if (fs.existsSync(candidate))
|
|
26
|
-
return candidate;
|
|
27
|
-
const parent = path.dirname(dir);
|
|
28
|
-
if (parent === dir)
|
|
29
|
-
return null;
|
|
30
|
-
dir = parent;
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
/* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
|
|
34
|
-
const loadContext = () => {
|
|
35
|
-
/* find files */
|
|
36
|
-
const cfgPath = findUpward(process.cwd(), ".ase/config.yaml");
|
|
37
|
-
const svcPath = findUpward(process.cwd(), ".ase/service.yaml");
|
|
38
|
-
/* determine project id */
|
|
39
|
-
let projectId;
|
|
40
|
-
if (cfgPath !== null) {
|
|
41
|
-
const doc = parseDocument(fs.readFileSync(cfgPath, "utf8"));
|
|
42
|
-
projectId = doc.get("project-id");
|
|
43
|
-
}
|
|
44
|
-
if (projectId === undefined || projectId === null)
|
|
45
|
-
projectId = path.basename(process.cwd());
|
|
46
|
-
if (typeof projectId !== "string" || projectId.length === 0)
|
|
47
|
-
throw new Error(`invalid "project-id" in ${cfgPath ?? "<cwd basename>"}`);
|
|
48
|
-
/* determine service port */
|
|
49
|
-
let port = null;
|
|
50
|
-
if (svcPath !== null) {
|
|
51
|
-
const doc = parseDocument(fs.readFileSync(svcPath, "utf8"));
|
|
52
|
-
const raw = doc.get("port");
|
|
53
|
-
if (raw !== undefined && raw !== null) {
|
|
54
|
-
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1024 || raw > 65535)
|
|
55
|
-
throw new Error(`invalid "port" in ${svcPath} (expected integer 1024..65535)`);
|
|
56
|
-
port = raw;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
/* determine path to ".ase" directory */
|
|
60
|
-
const aseDir = cfgPath !== null ? path.dirname(cfgPath) :
|
|
61
|
-
svcPath !== null ? path.dirname(svcPath) :
|
|
62
|
-
path.join(process.cwd(), ".ase");
|
|
63
|
-
/* determine path to final ".ase/service.yaml" file */
|
|
64
|
-
const finalSvc = svcPath !== null ? svcPath : path.join(aseDir, "service.yaml");
|
|
65
|
-
/* return context information */
|
|
66
|
-
return {
|
|
67
|
-
projectId,
|
|
68
|
-
port,
|
|
69
|
-
svcPath: finalSvc,
|
|
70
|
-
aseDir
|
|
71
|
-
};
|
|
72
|
-
};
|
|
25
|
+
/* schema for ".ase/service.yaml" */
|
|
26
|
+
const serviceSchema = v.nullish(v.strictObject({
|
|
27
|
+
port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
|
|
28
|
+
}));
|
|
73
29
|
/* try binding a single candidate port to verify availability */
|
|
74
30
|
const tryBind = (port) => {
|
|
75
31
|
return new Promise((resolve) => {
|
|
@@ -93,20 +49,29 @@ const allocatePort = async () => {
|
|
|
93
49
|
throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
|
|
94
50
|
};
|
|
95
51
|
/* persist an allocated port into ".ase/service.yaml" */
|
|
96
|
-
const persistPort = (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
52
|
+
const persistPort = (svc, port) => {
|
|
53
|
+
svc.set("port", port);
|
|
54
|
+
svc.write();
|
|
55
|
+
};
|
|
56
|
+
/* clear the persisted port and remove ".ase/service.yaml" if it is empty */
|
|
57
|
+
const clearPort = (svc) => {
|
|
58
|
+
svc.delete("port");
|
|
59
|
+
const root = svc.get();
|
|
60
|
+
const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
|
|
61
|
+
if (empty) {
|
|
62
|
+
if (fs.existsSync(svc.filename))
|
|
63
|
+
fs.rmSync(svc.filename);
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
svc.write();
|
|
102
67
|
};
|
|
103
68
|
/* distinguish ECONNREFUSED from other Axios transport errors */
|
|
104
69
|
const isConnRefused = (err) => {
|
|
105
70
|
const e = err;
|
|
106
71
|
return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
|
|
107
72
|
};
|
|
108
|
-
/* probe the service */
|
|
109
|
-
const probe = async (port) => {
|
|
73
|
+
/* probe the service and verify ASE identity banner */
|
|
74
|
+
const probe = async (port, projectId) => {
|
|
110
75
|
try {
|
|
111
76
|
const r = await axios.request({
|
|
112
77
|
method: "OPTIONS",
|
|
@@ -114,7 +79,10 @@ const probe = async (port) => {
|
|
|
114
79
|
timeout: 2000,
|
|
115
80
|
validateStatus: () => true
|
|
116
81
|
});
|
|
117
|
-
|
|
82
|
+
if (r.status < 200 || r.status >= 300)
|
|
83
|
+
return false;
|
|
84
|
+
const d = r.data;
|
|
85
|
+
return d?.ase === true && d?.projectId === projectId;
|
|
118
86
|
}
|
|
119
87
|
catch (err) {
|
|
120
88
|
if (isConnRefused(err))
|
|
@@ -122,148 +90,305 @@ const probe = async (port) => {
|
|
|
122
90
|
throw err;
|
|
123
91
|
}
|
|
124
92
|
};
|
|
125
|
-
/* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
|
|
126
|
-
const runService = async (ctx) => {
|
|
127
|
-
/* establish HAPI HTTP/REST service */
|
|
128
|
-
const server = Hapi.server({ host: HOST, port: ctx.port });
|
|
129
|
-
/* track last activity */
|
|
130
|
-
let lastActivity = Date.now();
|
|
131
|
-
server.ext("onRequest", (_request, h) => {
|
|
132
|
-
lastActivity = Date.now();
|
|
133
|
-
return h.continue;
|
|
134
|
-
});
|
|
135
|
-
/* listen to HTTP/REST endpoints */
|
|
136
|
-
server.route({
|
|
137
|
-
method: "OPTIONS",
|
|
138
|
-
path: "/",
|
|
139
|
-
handler: (_request, h) => {
|
|
140
|
-
return h.response().code(204);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
server.route({
|
|
144
|
-
method: "GET",
|
|
145
|
-
path: "/stop",
|
|
146
|
-
handler: (_request, h) => {
|
|
147
|
-
setImmediate(async () => {
|
|
148
|
-
await server.stop({ timeout: 1000 });
|
|
149
|
-
process.exit(0);
|
|
150
|
-
});
|
|
151
|
-
return h.response({ ok: true }).code(200);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
server.route({
|
|
155
|
-
method: "POST",
|
|
156
|
-
path: "/command",
|
|
157
|
-
options: { payload: { parse: true, allow: "application/json" } },
|
|
158
|
-
handler: (request, h) => {
|
|
159
|
-
const payload = request.payload;
|
|
160
|
-
if (!payload || typeof payload.command !== "string")
|
|
161
|
-
return h.response({ error: "missing or invalid 'command' field" }).code(400);
|
|
162
|
-
if (payload.command === "foo") {
|
|
163
|
-
return h.response({
|
|
164
|
-
ok: true,
|
|
165
|
-
projectId: ctx.projectId,
|
|
166
|
-
command: "Hello World" // FIXME
|
|
167
|
-
}).code(200);
|
|
168
|
-
}
|
|
169
|
-
else
|
|
170
|
-
return h.response({ error: "invalid 'command' field" }).code(400);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
/* start service */
|
|
174
|
-
try {
|
|
175
|
-
await server.start();
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
const e = err;
|
|
179
|
-
if (e.code === "EADDRINUSE") {
|
|
180
|
-
/* race-loser re-probe: another "ase service start" won the race */
|
|
181
|
-
const status = await probe(ctx.port).catch(() => null);
|
|
182
|
-
if (status !== null && status >= 200 && status < 300)
|
|
183
|
-
process.exit(0);
|
|
184
|
-
process.stderr.write(`ase: service: port ${ctx.port} in use, but not responding!\n`);
|
|
185
|
-
process.exit(1);
|
|
186
|
-
}
|
|
187
|
-
process.stderr.write(`ase: service: ${e.message}\n`);
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
/* stop service after idle timeout */
|
|
191
|
-
setInterval(() => {
|
|
192
|
-
if (Date.now() - lastActivity > IDLE_MS) {
|
|
193
|
-
server.stop({ timeout: 1000 }).then(() => {
|
|
194
|
-
process.exit(0);
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}, TICK_MS).unref();
|
|
198
|
-
};
|
|
199
93
|
/* spawn the current executable detached as a background service */
|
|
200
|
-
const spawnDetached = (aseDir) => {
|
|
94
|
+
const spawnDetached = (aseDir, port) => {
|
|
201
95
|
fs.mkdirSync(aseDir, { recursive: true });
|
|
202
96
|
const logFile = path.join(aseDir, "service.log");
|
|
203
|
-
const
|
|
204
|
-
const
|
|
97
|
+
const fd = fs.openSync(logFile, "a");
|
|
98
|
+
const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
|
|
99
|
+
const child = spawn(process.execPath, [entry, "service", "start"], {
|
|
205
100
|
detached: true,
|
|
206
|
-
env: { ...process.env, [SERVE_ENV]: "1" },
|
|
207
|
-
stdio: ["ignore",
|
|
101
|
+
env: { ...process.env, [SERVE_ENV]: "1", [PORT_ENV]: String(port) },
|
|
102
|
+
stdio: ["ignore", fd, fd]
|
|
208
103
|
});
|
|
209
|
-
|
|
104
|
+
fs.closeSync(fd);
|
|
105
|
+
return { child, logFile };
|
|
210
106
|
};
|
|
211
|
-
/*
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
107
|
+
/* read the last N non-empty lines of a log file for diagnostics */
|
|
108
|
+
const readLogTail = (logFile, lines) => {
|
|
109
|
+
let fd = null;
|
|
110
|
+
try {
|
|
111
|
+
fd = fs.openSync(logFile, "r");
|
|
112
|
+
const size = fs.fstatSync(fd).size;
|
|
113
|
+
const CHUNK = 8192;
|
|
114
|
+
const buf = Buffer.alloc(CHUNK);
|
|
115
|
+
let pos = size;
|
|
116
|
+
let tail = "";
|
|
117
|
+
let count = 0;
|
|
118
|
+
while (pos > 0 && count <= lines) {
|
|
119
|
+
const n = Math.min(CHUNK, pos);
|
|
120
|
+
pos -= n;
|
|
121
|
+
fs.readSync(fd, buf, 0, n, pos);
|
|
122
|
+
tail = buf.toString("utf8", 0, n) + tail;
|
|
123
|
+
count = 0;
|
|
124
|
+
for (let i = 0; i < tail.length; i++)
|
|
125
|
+
if (tail.charCodeAt(i) === 10)
|
|
126
|
+
count++;
|
|
127
|
+
}
|
|
128
|
+
const all = tail.split("\n").filter((l) => l.length > 0);
|
|
129
|
+
return all.slice(-lines).join("\n");
|
|
218
130
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return await new Promise(() => { });
|
|
131
|
+
catch {
|
|
132
|
+
return "";
|
|
222
133
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
spawnDetached(ctx.aseDir);
|
|
227
|
-
for (let i = 0; i < 50; i++) {
|
|
228
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
229
|
-
const s = await probe(port);
|
|
230
|
-
if (s !== null && s >= 200 && s < 300)
|
|
231
|
-
return 0;
|
|
134
|
+
finally {
|
|
135
|
+
if (fd !== null)
|
|
136
|
+
fs.closeSync(fd);
|
|
232
137
|
}
|
|
233
|
-
throw new Error("service failed to start within timeout");
|
|
234
138
|
};
|
|
235
|
-
/*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
139
|
+
/* CLI command "ase service" */
|
|
140
|
+
export default class ServiceCommand {
|
|
141
|
+
log;
|
|
142
|
+
constructor(log) {
|
|
143
|
+
this.log = log;
|
|
144
|
+
}
|
|
145
|
+
/* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
|
|
146
|
+
loadContext() {
|
|
147
|
+
/* load files */
|
|
148
|
+
const cfg = new Config("config", configSchema, this.log);
|
|
149
|
+
cfg.read();
|
|
150
|
+
const svc = new Config("service", serviceSchema, this.log);
|
|
151
|
+
svc.read();
|
|
152
|
+
/* determine project id */
|
|
153
|
+
const rawId = cfg.get("project.id");
|
|
154
|
+
const projectId = rawId ?? path.basename(process.cwd());
|
|
155
|
+
/* determine service port */
|
|
156
|
+
const rawPort = svc.get("port");
|
|
157
|
+
const port = rawPort ?? null;
|
|
158
|
+
/* determine path to ".ase" directory */
|
|
159
|
+
const aseDir = path.dirname(svc.filename);
|
|
160
|
+
/* return context information */
|
|
161
|
+
return {
|
|
162
|
+
projectId,
|
|
163
|
+
port,
|
|
164
|
+
svc,
|
|
165
|
+
aseDir
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
|
|
169
|
+
async runService(ctx) {
|
|
170
|
+
/* establish HAPI HTTP/REST service */
|
|
171
|
+
const server = Hapi.server({ host: HOST, port: ctx.port });
|
|
172
|
+
/* track start time and last activity */
|
|
173
|
+
const startTime = Date.now();
|
|
174
|
+
let lastActivity = Date.now();
|
|
175
|
+
let stopping = false;
|
|
176
|
+
server.ext("onRequest", (_request, h) => {
|
|
177
|
+
lastActivity = Date.now();
|
|
178
|
+
return h.continue;
|
|
179
|
+
});
|
|
180
|
+
/* listen to HTTP/REST endpoints */
|
|
181
|
+
server.route({
|
|
182
|
+
method: "OPTIONS",
|
|
183
|
+
path: "/",
|
|
184
|
+
handler: (_request, h) => {
|
|
185
|
+
return h.response({ ase: true, projectId: ctx.projectId }).code(200);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
server.route({
|
|
242
189
|
method: "GET",
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
190
|
+
path: "/stop",
|
|
191
|
+
handler: (_request, h) => {
|
|
192
|
+
setImmediate(async () => {
|
|
193
|
+
await server.stop({ timeout: 1000 });
|
|
194
|
+
process.exit(0);
|
|
195
|
+
});
|
|
196
|
+
return h.response({ ok: true }).code(200);
|
|
197
|
+
}
|
|
246
198
|
});
|
|
247
|
-
|
|
199
|
+
server.route({
|
|
200
|
+
method: "POST",
|
|
201
|
+
path: "/command",
|
|
202
|
+
options: { payload: { parse: true, allow: "application/json" } },
|
|
203
|
+
handler: (request, h) => {
|
|
204
|
+
const payload = request.payload;
|
|
205
|
+
if (!payload || typeof payload.command !== "string")
|
|
206
|
+
return h.response({ error: "missing or invalid 'command' field" }).code(400);
|
|
207
|
+
const cmd = payload.command;
|
|
208
|
+
if (cmd === "ping")
|
|
209
|
+
return h.response({ ok: true, pong: true }).code(200);
|
|
210
|
+
if (cmd === "status")
|
|
211
|
+
return h.response({
|
|
212
|
+
ok: true,
|
|
213
|
+
projectId: ctx.projectId,
|
|
214
|
+
port: ctx.port,
|
|
215
|
+
uptimeMs: Date.now() - startTime
|
|
216
|
+
}).code(200);
|
|
217
|
+
return h.response({ error: "unknown command", command: cmd }).code(400);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
/* start service */
|
|
221
|
+
try {
|
|
222
|
+
await server.start();
|
|
223
|
+
persistPort(ctx.svc, ctx.port);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const e = err;
|
|
227
|
+
if (e.code === "EADDRINUSE") {
|
|
228
|
+
/* race-loser re-probe: another "ase service start" won the race */
|
|
229
|
+
const match = await probe(ctx.port, ctx.projectId).catch(() => null);
|
|
230
|
+
if (match === true)
|
|
231
|
+
process.exit(0);
|
|
232
|
+
this.log.write("error", `service: port ${ctx.port} in use, but not responding!`);
|
|
233
|
+
clearPort(ctx.svc);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
this.log.write("error", `service: ${e.message}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
/* stop service after idle timeout */
|
|
240
|
+
setInterval(async () => {
|
|
241
|
+
if (stopping)
|
|
242
|
+
return;
|
|
243
|
+
if (Date.now() - lastActivity > IDLE_MS) {
|
|
244
|
+
stopping = true;
|
|
245
|
+
try {
|
|
246
|
+
await server.stop({ timeout: 1000 });
|
|
247
|
+
clearPort(ctx.svc);
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const e = err;
|
|
252
|
+
this.log.write("error", `service: stop failed: ${e.message}`);
|
|
253
|
+
clearPort(ctx.svc);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}, TICK_MS).unref();
|
|
248
258
|
}
|
|
249
|
-
|
|
250
|
-
|
|
259
|
+
/* start flow: ensure port, probe, optionally detach */
|
|
260
|
+
async doStart() {
|
|
261
|
+
const ctx = this.loadContext();
|
|
262
|
+
let port = ctx.port;
|
|
263
|
+
if (process.env[SERVE_ENV] === "1") {
|
|
264
|
+
const raw = process.env[PORT_ENV];
|
|
265
|
+
port = raw !== undefined ? Number(raw) : await allocatePort();
|
|
266
|
+
await this.runService({ ...ctx, port });
|
|
267
|
+
return await new Promise(() => { });
|
|
268
|
+
}
|
|
269
|
+
if (port !== null) {
|
|
270
|
+
const match = await probe(port, ctx.projectId);
|
|
271
|
+
if (match === true) {
|
|
272
|
+
this.log.write("info", `service: already running on port ${port}`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/* bounded retry across the bind/start TOCTOU window: on each attempt
|
|
277
|
+
re-allocate, re-persist, re-spawn; early-break on foreign listener */
|
|
278
|
+
let lastErr = new Error("service failed to start within timeout");
|
|
279
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
280
|
+
port = await allocatePort();
|
|
281
|
+
const { child, logFile } = spawnDetached(ctx.aseDir, port);
|
|
282
|
+
let exited = false;
|
|
283
|
+
let exitCode = null;
|
|
284
|
+
let resolveExit = () => { };
|
|
285
|
+
const exitPromise = new Promise((resolve) => {
|
|
286
|
+
resolveExit = resolve;
|
|
287
|
+
});
|
|
288
|
+
const onExit = (code) => {
|
|
289
|
+
exited = true;
|
|
290
|
+
exitCode = code;
|
|
291
|
+
resolveExit();
|
|
292
|
+
};
|
|
293
|
+
child.once("exit", onExit);
|
|
294
|
+
let foreign = false;
|
|
295
|
+
let success = false;
|
|
296
|
+
try {
|
|
297
|
+
for (let i = 0; i < 50; i++) {
|
|
298
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
299
|
+
if (exited)
|
|
300
|
+
break;
|
|
301
|
+
const s = await probe(port, ctx.projectId);
|
|
302
|
+
if (s === true) {
|
|
303
|
+
this.log.write("info", `service: started on port ${port}`);
|
|
304
|
+
child.unref();
|
|
305
|
+
success = true;
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
if (s === false) {
|
|
309
|
+
foreign = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const tail = readLogTail(logFile, 20);
|
|
314
|
+
const reason = exited ?
|
|
315
|
+
`service exited during startup (code ${exitCode})` :
|
|
316
|
+
foreign ?
|
|
317
|
+
`service lost port ${port} race to a foreign listener` :
|
|
318
|
+
"service failed to start within timeout";
|
|
319
|
+
const detail = tail.length > 0 ? `\n---- ${logFile} (tail) ----\n${tail}` : "";
|
|
320
|
+
lastErr = new Error(`${reason}${detail}`);
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
child.removeListener("exit", onExit);
|
|
324
|
+
if (!success && !exited) {
|
|
325
|
+
child.kill("SIGTERM");
|
|
326
|
+
await Promise.race([
|
|
327
|
+
exitPromise,
|
|
328
|
+
new Promise((resolve) => setTimeout(resolve, 1000))
|
|
329
|
+
]);
|
|
330
|
+
if (!exited) {
|
|
331
|
+
child.kill("SIGKILL");
|
|
332
|
+
await exitPromise;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
clearPort(ctx.svc);
|
|
338
|
+
throw lastErr;
|
|
339
|
+
}
|
|
340
|
+
/* status flow: report whether the service is running */
|
|
341
|
+
async doStatus() {
|
|
342
|
+
const ctx = this.loadContext();
|
|
343
|
+
if (ctx.port === null) {
|
|
344
|
+
process.stdout.write("service: not running (no port configured)\n");
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
const match = await probe(ctx.port, ctx.projectId);
|
|
348
|
+
if (match === true) {
|
|
349
|
+
const r = await axios.request({
|
|
350
|
+
method: "POST",
|
|
351
|
+
url: `http://${HOST}:${ctx.port}/command`,
|
|
352
|
+
headers: { "Content-Type": "application/json" },
|
|
353
|
+
data: { command: "status" },
|
|
354
|
+
timeout: 2000,
|
|
355
|
+
validateStatus: () => true
|
|
356
|
+
});
|
|
357
|
+
const d = r.data;
|
|
358
|
+
const uptimeMs = typeof d?.uptimeMs === "number" ? d.uptimeMs : 0;
|
|
359
|
+
const uptime = prettyMs(uptimeMs, { verbose: true });
|
|
360
|
+
process.stdout.write(`service: running on port ${ctx.port} (uptime: ${uptime})\n`);
|
|
251
361
|
return 0;
|
|
252
|
-
|
|
362
|
+
}
|
|
363
|
+
if (match === false) {
|
|
364
|
+
process.stdout.write(`service: not running (port ${ctx.port} in use by foreign service)\n`);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
process.stdout.write(`service: not running (port ${ctx.port} not responding)\n`);
|
|
368
|
+
return 1;
|
|
253
369
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
370
|
+
/* send command: POST /command with the arbitrary cmd token */
|
|
371
|
+
async doSend(cmd) {
|
|
372
|
+
let ctx = this.loadContext();
|
|
373
|
+
if (ctx.port === null) {
|
|
374
|
+
await this.doStart();
|
|
375
|
+
ctx = this.loadContext();
|
|
376
|
+
if (ctx.port === null)
|
|
377
|
+
throw new Error("service not running (no port configured after auto-start)");
|
|
378
|
+
}
|
|
379
|
+
const match = await probe(ctx.port, ctx.projectId);
|
|
380
|
+
if (match !== true) {
|
|
381
|
+
await this.doStart();
|
|
382
|
+
ctx = this.loadContext();
|
|
383
|
+
if (ctx.port === null)
|
|
384
|
+
throw new Error("service not running (no port configured after auto-start)");
|
|
385
|
+
}
|
|
261
386
|
const r = await axios.request({
|
|
262
387
|
method: "POST",
|
|
263
388
|
url: `http://${HOST}:${ctx.port}/command`,
|
|
264
389
|
headers: { "Content-Type": "application/json" },
|
|
265
390
|
data: { command: cmd },
|
|
266
|
-
timeout:
|
|
391
|
+
timeout: 5000,
|
|
267
392
|
validateStatus: () => true,
|
|
268
393
|
responseType: "text",
|
|
269
394
|
transformResponse: [(x) => x]
|
|
@@ -274,36 +399,71 @@ const doPassthrough = async (cmd) => {
|
|
|
274
399
|
process.stdout.write("\n");
|
|
275
400
|
return r.status >= 200 && r.status < 300 ? 0 : 1;
|
|
276
401
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
402
|
+
/* stop flow: no-op if no port configured or connection refused */
|
|
403
|
+
async doStop() {
|
|
404
|
+
const ctx = this.loadContext();
|
|
405
|
+
if (ctx.port === null) {
|
|
406
|
+
this.log.write("info", "service: not running (no port configured)");
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
const match = await probe(ctx.port, ctx.projectId);
|
|
410
|
+
if (match === false) {
|
|
411
|
+
this.log.write("info", `service: not running (port ${ctx.port} in use by foreign service)`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
if (match === null) {
|
|
415
|
+
this.log.write("info", `service: not running (port ${ctx.port} not responding)`);
|
|
416
|
+
clearPort(ctx.svc);
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
const r = await axios.request({
|
|
420
|
+
method: "GET",
|
|
421
|
+
url: `http://${HOST}:${ctx.port}/stop`,
|
|
422
|
+
timeout: 5000,
|
|
423
|
+
validateStatus: () => true
|
|
424
|
+
});
|
|
425
|
+
const ok = r.status >= 200 && r.status < 300;
|
|
426
|
+
clearPort(ctx.svc);
|
|
427
|
+
return ok ? 0 : 1;
|
|
281
428
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
.
|
|
290
|
-
process.exit(
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
/*
|
|
429
|
+
/* register commands */
|
|
430
|
+
register(program) {
|
|
431
|
+
/* register CLI top-level command "ase service" */
|
|
432
|
+
const service = program
|
|
433
|
+
.command("service")
|
|
434
|
+
.description("Manage per-project background HTTP service")
|
|
435
|
+
.action(() => {
|
|
436
|
+
service.outputHelp();
|
|
437
|
+
process.exit(1);
|
|
438
|
+
});
|
|
439
|
+
/* register CLI sub-command "ase service start" */
|
|
440
|
+
service
|
|
441
|
+
.command("start")
|
|
442
|
+
.description("Start the background service")
|
|
443
|
+
.action(async () => {
|
|
444
|
+
process.exit(await this.doStart());
|
|
445
|
+
});
|
|
446
|
+
/* register CLI sub-command "ase service status" */
|
|
447
|
+
service
|
|
448
|
+
.command("status")
|
|
449
|
+
.description("Report whether the background service is running")
|
|
450
|
+
.action(async () => {
|
|
451
|
+
process.exit(await this.doStatus());
|
|
452
|
+
});
|
|
453
|
+
/* register CLI sub-command "ase service send" */
|
|
454
|
+
service
|
|
455
|
+
.command("send")
|
|
456
|
+
.description("Send a command to the background service")
|
|
457
|
+
.argument("<cmd>", "Command token to dispatch to the service")
|
|
458
|
+
.action(async (cmd) => {
|
|
459
|
+
process.exit(await this.doSend(cmd));
|
|
460
|
+
});
|
|
461
|
+
/* register CLI sub-command "ase service stop" */
|
|
462
|
+
service
|
|
463
|
+
.command("stop")
|
|
464
|
+
.description("Stop the background service")
|
|
465
|
+
.action(async () => {
|
|
466
|
+
process.exit(await this.doStop());
|
|
467
|
+
});
|
|
307
468
|
}
|
|
308
|
-
}
|
|
309
|
-
export default serviceCommand;
|
|
469
|
+
}
|