@rse/ase 0.0.7 → 0.0.9
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-config.js +562 -108
- package/dst/ase-log.js +69 -0
- package/dst/ase-service.js +365 -198
- package/dst/ase.1 +27 -10
- package/dst/ase.js +29 -12
- package/package.json +8 -4
- package/README.md +0 -42
package/dst/ase-service.js
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
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
11
|
import Hapi from "@hapi/hapi";
|
|
11
12
|
import axios from "axios";
|
|
13
|
+
import { isMap } from "yaml";
|
|
12
14
|
import * as v from "valibot";
|
|
15
|
+
import prettyMs from "pretty-ms";
|
|
13
16
|
import { Config, configSchema } from "./ase-config.js";
|
|
14
17
|
const SERVE_ENV = "ASE_SERVICE_SERVE";
|
|
18
|
+
const PORT_ENV = "ASE_SERVICE_PORT";
|
|
15
19
|
const HOST = "127.0.0.1";
|
|
16
20
|
const IDLE_MS = 30 * 60 * 1000;
|
|
17
21
|
const TICK_MS = 60 * 1000;
|
|
@@ -22,29 +26,6 @@ const PORT_TRIES = 20;
|
|
|
22
26
|
const serviceSchema = v.nullish(v.strictObject({
|
|
23
27
|
port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
|
|
24
28
|
}));
|
|
25
|
-
/* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
|
|
26
|
-
const loadContext = () => {
|
|
27
|
-
/* load files */
|
|
28
|
-
const cfg = new Config("config", configSchema);
|
|
29
|
-
cfg.read();
|
|
30
|
-
const svc = new Config("service", serviceSchema);
|
|
31
|
-
svc.read();
|
|
32
|
-
/* determine project id */
|
|
33
|
-
const rawId = cfg.get("project.id");
|
|
34
|
-
const projectId = (rawId === undefined || rawId === null) ? path.basename(process.cwd()) : rawId;
|
|
35
|
-
/* determine service port */
|
|
36
|
-
const rawPort = svc.get("port");
|
|
37
|
-
const port = (rawPort === undefined || rawPort === null) ? null : rawPort;
|
|
38
|
-
/* determine path to ".ase" directory */
|
|
39
|
-
const aseDir = path.dirname(svc.filename);
|
|
40
|
-
/* return context information */
|
|
41
|
-
return {
|
|
42
|
-
projectId,
|
|
43
|
-
port,
|
|
44
|
-
svc,
|
|
45
|
-
aseDir
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
29
|
/* try binding a single candidate port to verify availability */
|
|
49
30
|
const tryBind = (port) => {
|
|
50
31
|
return new Promise((resolve) => {
|
|
@@ -72,13 +53,25 @@ const persistPort = (svc, port) => {
|
|
|
72
53
|
svc.set("port", port);
|
|
73
54
|
svc.write();
|
|
74
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();
|
|
67
|
+
};
|
|
75
68
|
/* distinguish ECONNREFUSED from other Axios transport errors */
|
|
76
69
|
const isConnRefused = (err) => {
|
|
77
70
|
const e = err;
|
|
78
71
|
return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
|
|
79
72
|
};
|
|
80
|
-
/* probe the service */
|
|
81
|
-
const probe = async (port) => {
|
|
73
|
+
/* probe the service and verify ASE identity banner */
|
|
74
|
+
const probe = async (port, projectId) => {
|
|
82
75
|
try {
|
|
83
76
|
const r = await axios.request({
|
|
84
77
|
method: "OPTIONS",
|
|
@@ -86,7 +79,10 @@ const probe = async (port) => {
|
|
|
86
79
|
timeout: 2000,
|
|
87
80
|
validateStatus: () => true
|
|
88
81
|
});
|
|
89
|
-
|
|
82
|
+
if (r.status < 200 || r.status >= 300)
|
|
83
|
+
return false;
|
|
84
|
+
const d = r.data;
|
|
85
|
+
return d?.ase === true && d?.projectId === projectId;
|
|
90
86
|
}
|
|
91
87
|
catch (err) {
|
|
92
88
|
if (isConnRefused(err))
|
|
@@ -94,158 +90,305 @@ const probe = async (port) => {
|
|
|
94
90
|
throw err;
|
|
95
91
|
}
|
|
96
92
|
};
|
|
97
|
-
/* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
|
|
98
|
-
const runService = async (ctx) => {
|
|
99
|
-
/* establish HAPI HTTP/REST service */
|
|
100
|
-
const server = Hapi.server({ host: HOST, port: ctx.port });
|
|
101
|
-
/* track last activity */
|
|
102
|
-
let lastActivity = Date.now();
|
|
103
|
-
server.ext("onRequest", (_request, h) => {
|
|
104
|
-
lastActivity = Date.now();
|
|
105
|
-
return h.continue;
|
|
106
|
-
});
|
|
107
|
-
/* listen to HTTP/REST endpoints */
|
|
108
|
-
server.route({
|
|
109
|
-
method: "OPTIONS",
|
|
110
|
-
path: "/",
|
|
111
|
-
handler: (_request, h) => {
|
|
112
|
-
return h.response().code(204);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
server.route({
|
|
116
|
-
method: "GET",
|
|
117
|
-
path: "/stop",
|
|
118
|
-
handler: (_request, h) => {
|
|
119
|
-
setImmediate(async () => {
|
|
120
|
-
await server.stop({ timeout: 1000 });
|
|
121
|
-
process.exit(0);
|
|
122
|
-
});
|
|
123
|
-
return h.response({ ok: true }).code(200);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
server.route({
|
|
127
|
-
method: "POST",
|
|
128
|
-
path: "/command",
|
|
129
|
-
options: { payload: { parse: true, allow: "application/json" } },
|
|
130
|
-
handler: (request, h) => {
|
|
131
|
-
const payload = request.payload;
|
|
132
|
-
if (!payload || typeof payload.command !== "string")
|
|
133
|
-
return h.response({ error: "missing or invalid 'command' field" }).code(400);
|
|
134
|
-
if (payload.command === "foo") {
|
|
135
|
-
return h.response({
|
|
136
|
-
ok: true,
|
|
137
|
-
projectId: ctx.projectId,
|
|
138
|
-
command: "Hello World" // FIXME
|
|
139
|
-
}).code(200);
|
|
140
|
-
}
|
|
141
|
-
else
|
|
142
|
-
return h.response({ error: "invalid 'command' field" }).code(400);
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
/* start service */
|
|
146
|
-
try {
|
|
147
|
-
await server.start();
|
|
148
|
-
}
|
|
149
|
-
catch (err) {
|
|
150
|
-
const e = err;
|
|
151
|
-
if (e.code === "EADDRINUSE") {
|
|
152
|
-
/* race-loser re-probe: another "ase service start" won the race */
|
|
153
|
-
const status = await probe(ctx.port).catch(() => null);
|
|
154
|
-
if (status !== null && status >= 200 && status < 300)
|
|
155
|
-
process.exit(0);
|
|
156
|
-
process.stderr.write(`ase: service: port ${ctx.port} in use, but not responding!\n`);
|
|
157
|
-
process.exit(1);
|
|
158
|
-
}
|
|
159
|
-
process.stderr.write(`ase: service: ${e.message}\n`);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
}
|
|
162
|
-
/* stop service after idle timeout */
|
|
163
|
-
setInterval(() => {
|
|
164
|
-
if (Date.now() - lastActivity > IDLE_MS) {
|
|
165
|
-
server.stop({ timeout: 1000 }).then(() => {
|
|
166
|
-
process.exit(0);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
}, TICK_MS).unref();
|
|
170
|
-
};
|
|
171
93
|
/* spawn the current executable detached as a background service */
|
|
172
|
-
const spawnDetached = (aseDir) => {
|
|
94
|
+
const spawnDetached = (aseDir, port) => {
|
|
173
95
|
fs.mkdirSync(aseDir, { recursive: true });
|
|
174
96
|
const logFile = path.join(aseDir, "service.log");
|
|
175
|
-
const
|
|
176
|
-
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"], {
|
|
177
100
|
detached: true,
|
|
178
|
-
env: { ...process.env, [SERVE_ENV]: "1" },
|
|
179
|
-
stdio: ["ignore",
|
|
101
|
+
env: { ...process.env, [SERVE_ENV]: "1", [PORT_ENV]: String(port) },
|
|
102
|
+
stdio: ["ignore", fd, fd]
|
|
180
103
|
});
|
|
181
|
-
|
|
104
|
+
fs.closeSync(fd);
|
|
105
|
+
return { child, logFile };
|
|
182
106
|
};
|
|
183
|
-
/*
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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");
|
|
190
130
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return await new Promise(() => { });
|
|
131
|
+
catch {
|
|
132
|
+
return "";
|
|
194
133
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
spawnDetached(ctx.aseDir);
|
|
199
|
-
for (let i = 0; i < 50; i++) {
|
|
200
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
201
|
-
const s = await probe(port);
|
|
202
|
-
if (s !== null && s >= 200 && s < 300) {
|
|
203
|
-
process.stdout.write(`ase: service: started on port ${port}\n`);
|
|
204
|
-
return 0;
|
|
205
|
-
}
|
|
134
|
+
finally {
|
|
135
|
+
if (fd !== null)
|
|
136
|
+
fs.closeSync(fd);
|
|
206
137
|
}
|
|
207
|
-
throw new Error("service failed to start within timeout");
|
|
208
138
|
};
|
|
209
|
-
/*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return 0;
|
|
139
|
+
/* CLI command "ase service" */
|
|
140
|
+
export default class ServiceCommand {
|
|
141
|
+
log;
|
|
142
|
+
constructor(log) {
|
|
143
|
+
this.log = log;
|
|
215
144
|
}
|
|
216
|
-
|
|
217
|
-
|
|
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({
|
|
218
189
|
method: "GET",
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
}
|
|
222
198
|
});
|
|
223
|
-
|
|
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();
|
|
224
258
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(() => { });
|
|
229
268
|
}
|
|
230
|
-
|
|
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;
|
|
231
339
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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`);
|
|
361
|
+
return 0;
|
|
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;
|
|
241
369
|
}
|
|
242
|
-
|
|
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
|
+
}
|
|
243
386
|
const r = await axios.request({
|
|
244
387
|
method: "POST",
|
|
245
388
|
url: `http://${HOST}:${ctx.port}/command`,
|
|
246
389
|
headers: { "Content-Type": "application/json" },
|
|
247
390
|
data: { command: cmd },
|
|
248
|
-
timeout:
|
|
391
|
+
timeout: 5000,
|
|
249
392
|
validateStatus: () => true,
|
|
250
393
|
responseType: "text",
|
|
251
394
|
transformResponse: [(x) => x]
|
|
@@ -255,48 +398,72 @@ const doPassthrough = async (cmd) => {
|
|
|
255
398
|
if (!body.endsWith("\n"))
|
|
256
399
|
process.stdout.write("\n");
|
|
257
400
|
return r.status >= 200 && r.status < 300 ? 0 : 1;
|
|
258
|
-
};
|
|
259
|
-
try {
|
|
260
|
-
return await send();
|
|
261
401
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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;
|
|
266
408
|
}
|
|
267
|
-
|
|
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;
|
|
268
428
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|