@rse/ase 0.0.7 → 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.
@@ -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
- return r.status;
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 log = fs.openSync(logFile, "a");
176
- const child = spawn(process.execPath, [process.argv[1], "service", "start"], {
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", log, log]
101
+ env: { ...process.env, [SERVE_ENV]: "1", [PORT_ENV]: String(port) },
102
+ stdio: ["ignore", fd, fd]
180
103
  });
181
- child.unref();
104
+ fs.closeSync(fd);
105
+ return { child, logFile };
182
106
  };
183
- /* start flow: ensure port, probe, optionally detach */
184
- const doStart = async () => {
185
- const ctx = loadContext();
186
- let port = ctx.port;
187
- if (port === null) {
188
- port = await allocatePort();
189
- persistPort(ctx.svc, port);
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
- if (process.env[SERVE_ENV] === "1") {
192
- await runService({ ...ctx, port });
193
- return await new Promise(() => { });
131
+ catch {
132
+ return "";
194
133
  }
195
- const status = await probe(port);
196
- if (status !== null && status >= 200 && status < 300)
197
- return 0;
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
- /* stop flow: no-op if no port configured or connection refused */
210
- const doStop = async () => {
211
- const ctx = loadContext();
212
- if (ctx.port === null) {
213
- process.stdout.write("ase: service: not running (no port configured)\n");
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
- try {
217
- const r = await axios.request({
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
- url: `http://${HOST}:${ctx.port}/stop`,
220
- timeout: 5000,
221
- validateStatus: () => true
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
- return r.status >= 200 && r.status < 300 ? 0 : 1;
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
- catch (err) {
226
- if (isConnRefused(err)) {
227
- process.stdout.write(`ase: service: not running (port ${ctx.port} not responding)\n`);
228
- return 0;
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
- throw err;
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
- /* passthrough flow: POST /command with the arbitrary cmd token */
234
- const doPassthrough = async (cmd) => {
235
- let ctx = loadContext();
236
- if (ctx.port === null) {
237
- await doStart();
238
- ctx = loadContext();
239
- if (ctx.port === null)
240
- throw new Error("service not running (no port configured after auto-start)");
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
- const send = async () => {
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: 0,
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
- catch (err) {
263
- if (isConnRefused(err)) {
264
- await doStart();
265
- return await send();
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
- throw err;
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
- /* register CLI command "ase service" */
271
- const registerServiceCommand = (program) => {
272
- const service = program
273
- .command("service")
274
- .description("Manage per-project background HTTP service")
275
- .action(() => {
276
- service.outputHelp();
277
- process.exit(1);
278
- });
279
- /* register CLI sub-command "ase service start" */
280
- service
281
- .command("start")
282
- .description("Start the background service")
283
- .action(async () => {
284
- process.exit(await doStart());
285
- });
286
- /* register CLI sub-command "ase service stop" */
287
- service
288
- .command("stop")
289
- .description("Stop the background service")
290
- .action(async () => {
291
- process.exit(await doStop());
292
- });
293
- /* register CLI sub-command "ase service send" */
294
- service
295
- .command("send")
296
- .description("Send a command to the background service")
297
- .argument("<cmd>", "Command token to dispatch to the service")
298
- .action(async (cmd) => {
299
- process.exit(await doPassthrough(cmd));
300
- });
301
- };
302
- export default registerServiceCommand;
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
+ }