bm2 1.0.37 → 1.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +78 -76
- package/src/daemon.ts +62 -2
- package/src/index.ts +118 -20
- package/src/log-manager.ts +175 -117
- package/src/process-container.ts +9 -24
- package/src/process-manager.ts +31 -8
- package/src/types.ts +14 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,78 +1,80 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
2
|
+
"name": "bm2",
|
|
3
|
+
"version": "1.0.39",
|
|
4
|
+
"description": "A blazing-fast, full-featured process manager built entirely on Bun native APIs. The modern PM2 replacement — zero Node.js dependencies, pure Bun performance.",
|
|
5
|
+
"main": "src/api.ts",
|
|
6
|
+
"module": "src/api.ts",
|
|
7
|
+
"types": "src/api.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/api.ts",
|
|
10
|
+
"./cli": "./src/index.ts",
|
|
11
|
+
"./types": "./src/types.ts"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"bm2": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "bun run src/index.ts",
|
|
23
|
+
"dev": "bun run --watch src/index.ts",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"lint": "bun x tsc --noEmit",
|
|
26
|
+
"prepublishOnly": "bun test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"bun",
|
|
30
|
+
"process-manager",
|
|
31
|
+
"pm2",
|
|
32
|
+
"pm2-alternative",
|
|
33
|
+
"cluster",
|
|
34
|
+
"daemon",
|
|
35
|
+
"production",
|
|
36
|
+
"deployment",
|
|
37
|
+
"monitoring",
|
|
38
|
+
"prometheus",
|
|
39
|
+
"zero-downtime",
|
|
40
|
+
"reload",
|
|
41
|
+
"log-management",
|
|
42
|
+
"health-check",
|
|
43
|
+
"bm2"
|
|
44
|
+
],
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "MaxxPainn",
|
|
47
|
+
"email": "hello@maxxpainn.com",
|
|
48
|
+
"url": "https://maxxpainn.com"
|
|
49
|
+
},
|
|
50
|
+
"license": "GPL-3.0",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/bun-bm2/bm2.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/bun-bm2/bm2/issues",
|
|
57
|
+
"email": "hello@maxxpainn.com"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/bun-bm2/bm2#readme",
|
|
60
|
+
"engines": {
|
|
61
|
+
"bun": ">=1.0.0"
|
|
62
|
+
},
|
|
63
|
+
"os": [
|
|
64
|
+
"linux",
|
|
65
|
+
"darwin"
|
|
66
|
+
],
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"chalk": "^5.6.2",
|
|
69
|
+
"cli-table3": "^0.6.5",
|
|
70
|
+
"pidusage": "^4.0.1",
|
|
71
|
+
"ws": "^8.19.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/bun": "^1.3.9",
|
|
75
|
+
"@types/pidusage": "^2.0.5",
|
|
76
|
+
"@types/ws": "^8.18.1",
|
|
77
|
+
"bun-types": "latest",
|
|
78
|
+
"typescript": "^5.9.3"
|
|
79
|
+
}
|
|
78
80
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "./constants";
|
|
26
26
|
import { ensureDirs } from "./utils";
|
|
27
27
|
import type { DaemonMessage, DaemonResponse } from "./types";
|
|
28
|
-
import type { Server } from "bun";
|
|
28
|
+
import type { ReadableStreamController, Server } from "bun";
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
export default class Daemon {
|
|
@@ -48,6 +48,7 @@ export default class Daemon {
|
|
|
48
48
|
getServerOpts = () => ({
|
|
49
49
|
unix: DAEMON_SOCKET,
|
|
50
50
|
fetch: this.boundFetch,
|
|
51
|
+
idleTimeout: 0
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
|
|
@@ -100,6 +101,11 @@ export default class Daemon {
|
|
|
100
101
|
try {
|
|
101
102
|
|
|
102
103
|
const msg = (await req.json()) as DaemonMessage;
|
|
104
|
+
|
|
105
|
+
if (msg.mode == "stream") {
|
|
106
|
+
return this.handleStream(msg, req);
|
|
107
|
+
}
|
|
108
|
+
|
|
103
109
|
const response = await this.handleMessage(msg);
|
|
104
110
|
return Response.json(response);
|
|
105
111
|
|
|
@@ -110,6 +116,38 @@ export default class Daemon {
|
|
|
110
116
|
);
|
|
111
117
|
}
|
|
112
118
|
}
|
|
119
|
+
|
|
120
|
+
handleStream(msg: DaemonMessage, req: Request) {
|
|
121
|
+
|
|
122
|
+
//let controller: ReadableStreamDefaultController;
|
|
123
|
+
const self = this;
|
|
124
|
+
const signal: AbortSignal = req.signal;
|
|
125
|
+
|
|
126
|
+
const stream = new ReadableStream({
|
|
127
|
+
start(controller) {
|
|
128
|
+
|
|
129
|
+
self.handleStreamMessage(msg, controller, signal);
|
|
130
|
+
|
|
131
|
+
const keepAlive = setInterval(() => {
|
|
132
|
+
controller.enqueue(': ping\n\n'); // SSE comment – ignored by clients but counts as data
|
|
133
|
+
}, 5000); // every 5 seconds (less than 10s timeout)
|
|
134
|
+
|
|
135
|
+
// cleanup when client disconnects
|
|
136
|
+
signal.addEventListener("abort", () => {
|
|
137
|
+
clearInterval(keepAlive)
|
|
138
|
+
controller.close();
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return new Response(stream, {
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": "text/event-stream", // SSE style
|
|
146
|
+
"Cache-Control": "no-cache",
|
|
147
|
+
Connection: "keep-alive",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
113
151
|
|
|
114
152
|
// initialize MUST be called before startServer
|
|
115
153
|
startServer(): Server<any> {
|
|
@@ -118,9 +156,30 @@ export default class Daemon {
|
|
|
118
156
|
throw new Error("Daemon.initialize() must be called before startServer()");
|
|
119
157
|
}
|
|
120
158
|
|
|
121
|
-
this.server = Bun.serve(this.getServerOpts());
|
|
159
|
+
this.server = Bun.serve(this.getServerOpts() as any);
|
|
122
160
|
return this.server;
|
|
123
161
|
}
|
|
162
|
+
|
|
163
|
+
async handleStreamMessage(msg: DaemonMessage, streamController: ReadableStreamDefaultController, signal: AbortSignal) {
|
|
164
|
+
|
|
165
|
+
if (!this.initialized) {
|
|
166
|
+
await this.initialize();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pm = this.pm!;
|
|
170
|
+
//const dashboard = this.dashboard!;
|
|
171
|
+
//const moduleManager = this.moduleManager!;
|
|
172
|
+
//const metricsInterval = this.metricsInterval!;
|
|
173
|
+
|
|
174
|
+
switch (msg.type) {
|
|
175
|
+
case "streamLogs": {
|
|
176
|
+
await pm.streamLogs(msg.data.target, streamController, signal);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
124
183
|
|
|
125
184
|
async handleMessage(msg: DaemonMessage): Promise<DaemonResponse> {
|
|
126
185
|
try {
|
|
@@ -185,6 +244,7 @@ export default class Daemon {
|
|
|
185
244
|
const logs = await pm.getLogs(msg.data.target, msg.data.lines);
|
|
186
245
|
return { type: "logs", data: logs, success: true, id: msg.id };
|
|
187
246
|
}
|
|
247
|
+
|
|
188
248
|
case "flush": {
|
|
189
249
|
await pm.flushLogs(msg.data?.target);
|
|
190
250
|
return { type: "flush", success: true, id: msg.id };
|
package/src/index.ts
CHANGED
|
@@ -38,10 +38,13 @@ import type {
|
|
|
38
38
|
StartOptions,
|
|
39
39
|
EcosystemConfig,
|
|
40
40
|
ProcessState,
|
|
41
|
+
LogEntry,
|
|
42
|
+
LogItem,
|
|
41
43
|
} from "./types";
|
|
42
44
|
import { statusColor } from "./colors";
|
|
43
45
|
import { liveWatchProcess, printProcessTable } from "./process-table";
|
|
44
46
|
import Daemon from "./daemon";
|
|
47
|
+
import chalk from "chalk";
|
|
45
48
|
|
|
46
49
|
// ---------------------------------------------------------------------------
|
|
47
50
|
// Ensure directory structure exists
|
|
@@ -163,6 +166,58 @@ class BM2CLI {
|
|
|
163
166
|
return { type: "error", error: "Fetch Error", success: false };
|
|
164
167
|
}
|
|
165
168
|
}
|
|
169
|
+
|
|
170
|
+
async getDaemonStream<T>(data: DaemonMessage, callback: (data: T | null) => void) {
|
|
171
|
+
try {
|
|
172
|
+
|
|
173
|
+
await this.startDaemon();
|
|
174
|
+
|
|
175
|
+
const res = await fetch("http://localhost/command", {
|
|
176
|
+
unix: DAEMON_SOCKET,
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify(data)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (!res.body) {
|
|
183
|
+
console.error("No stream received");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reader = res.body.getReader();
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
let buffer = "";
|
|
192
|
+
|
|
193
|
+
while (true) {
|
|
194
|
+
|
|
195
|
+
const { value, done } = await reader.read();
|
|
196
|
+
|
|
197
|
+
if (done) break;
|
|
198
|
+
|
|
199
|
+
buffer += decoder.decode(value, { stream: true });
|
|
200
|
+
|
|
201
|
+
// split SSE messages
|
|
202
|
+
const parts = buffer.split("\n\n");
|
|
203
|
+
buffer = parts.pop()!;
|
|
204
|
+
|
|
205
|
+
for (const part of parts) {
|
|
206
|
+
const line = part.replace(/^data:\s*/, "").trim();
|
|
207
|
+
if (!line) continue;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const result = JSON.parse(line) as T;
|
|
211
|
+
callback(result)
|
|
212
|
+
} catch { }
|
|
213
|
+
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
console.log("getDaemonStream:", e, e.stack);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
166
221
|
|
|
167
222
|
// -------------------------------------------------------------------------
|
|
168
223
|
// Ecosystem config loader
|
|
@@ -413,7 +468,7 @@ class BM2CLI {
|
|
|
413
468
|
}
|
|
414
469
|
|
|
415
470
|
printProcessTable(res.data);
|
|
416
|
-
|
|
471
|
+
|
|
417
472
|
if (this.noDaemon) {
|
|
418
473
|
await new Promise(() => {});
|
|
419
474
|
}
|
|
@@ -565,31 +620,74 @@ class BM2CLI {
|
|
|
565
620
|
console.log();
|
|
566
621
|
}
|
|
567
622
|
}
|
|
568
|
-
|
|
623
|
+
|
|
569
624
|
async cmdLogs(args: string[]) {
|
|
570
|
-
|
|
625
|
+
|
|
626
|
+
let target: string | number = "all";
|
|
571
627
|
let lines = 20;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
628
|
+
let follow = false;
|
|
629
|
+
|
|
630
|
+
let i = 0;
|
|
631
|
+
|
|
632
|
+
while (i < args.length) {
|
|
633
|
+
const arg = args[i]!;
|
|
634
|
+
|
|
635
|
+
if ((arg === "--lines" || arg === "-l") && !Number.isNaN(Number(args[i + 1]))) {
|
|
636
|
+
lines = parseInt(args[i + 1]!);
|
|
637
|
+
i++; // skip value
|
|
638
|
+
} else if (arg.startsWith("--lines=")) {
|
|
639
|
+
lines = parseInt(arg.split("=")[1]!);
|
|
640
|
+
} else if (arg === "--follow" || arg === "-f") {
|
|
641
|
+
follow = true;
|
|
642
|
+
} else if (!arg.startsWith("-")) {
|
|
643
|
+
target = arg;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
i++;
|
|
575
647
|
}
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
648
|
+
|
|
649
|
+
const renderLog = (log: LogItem) => {
|
|
650
|
+
let line;
|
|
651
|
+
if (log.level == "err") {
|
|
652
|
+
line = chalk.red(`[ERROR] ${log.name} | ${log.ts}: ${log.msg}\n`)
|
|
653
|
+
} else {
|
|
654
|
+
line = chalk.white(`${chalk.cyan(`[OUTPUT] ${log.name} | ${log.ts}`)}: ${log.msg}\n`)
|
|
655
|
+
}
|
|
656
|
+
console.log(line)
|
|
581
657
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
658
|
+
|
|
659
|
+
if (follow) {
|
|
660
|
+
|
|
661
|
+
const opts: DaemonMessage = {
|
|
662
|
+
type: "streamLogs",
|
|
663
|
+
data: { target },
|
|
664
|
+
mode: "stream"
|
|
588
665
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
666
|
+
|
|
667
|
+
const callback = (log: LogItem | null) => {
|
|
668
|
+
if(log) renderLog(log)
|
|
592
669
|
}
|
|
670
|
+
|
|
671
|
+
await this.getDaemonStream<LogItem>(opts, callback);
|
|
672
|
+
|
|
673
|
+
} else {
|
|
674
|
+
|
|
675
|
+
const res = await this.sendToDaemon({
|
|
676
|
+
type: "logs",
|
|
677
|
+
data: { target, lines },
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (!res.success) {
|
|
681
|
+
console.error(colorize(`Error: ${res.error}`, "red"));
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const logs: LogItem[] = res.data ?? [];
|
|
686
|
+
|
|
687
|
+
for (let log of logs) {
|
|
688
|
+
renderLog(log)
|
|
689
|
+
}
|
|
690
|
+
|
|
593
691
|
}
|
|
594
692
|
}
|
|
595
693
|
|
package/src/log-manager.ts
CHANGED
|
@@ -15,12 +15,21 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { join, dirname } from "path";
|
|
18
|
-
import {
|
|
19
|
-
import { appendFile, stat, rename, unlink, readdir, access } from "fs/promises";
|
|
18
|
+
import { appendFile, rename, unlink, readdir } from "fs/promises";
|
|
20
19
|
import { LOG_DIR, DEFAULT_LOG_MAX_SIZE, DEFAULT_LOG_RETAIN } from "./constants";
|
|
21
|
-
import type { LogRotateOptions } from "./types";
|
|
20
|
+
import type { LogEntry, LogRotateOptions } from "./types";
|
|
21
|
+
import { watch } from "fs";
|
|
22
|
+
import type { ReadableStreamController } from "bun";
|
|
23
|
+
import { $ } from "bun"
|
|
24
|
+
import { EOL } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const isoRegex: RegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/;
|
|
27
|
+
|
|
28
|
+
// [__br__] = linebreak
|
|
29
|
+
const nl = "[__br__]"
|
|
22
30
|
|
|
23
31
|
export class LogManager {
|
|
32
|
+
|
|
24
33
|
private writeBuffers: Map<string, string[]> = new Map();
|
|
25
34
|
private flushTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
26
35
|
|
|
@@ -30,7 +39,8 @@ export class LogManager {
|
|
|
30
39
|
errFile: customErr || join(LOG_DIR, `${name}-${id}-error.log`),
|
|
31
40
|
};
|
|
32
41
|
}
|
|
33
|
-
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
async appendLog(filePath: string, data: string | Uint8Array) {
|
|
35
45
|
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
36
46
|
|
|
@@ -47,6 +57,32 @@ export class LogManager {
|
|
|
47
57
|
}, 100));
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
|
|
61
|
+
async appendJSONLog(filePath: string, msg: string) {
|
|
62
|
+
|
|
63
|
+
msg = msg.trim().replace(/[\r\n]+/g, nl);
|
|
64
|
+
|
|
65
|
+
const log: LogEntry = {
|
|
66
|
+
ts: new Date().toISOString(),
|
|
67
|
+
msg
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const line = JSON.stringify(log) + "\n";
|
|
71
|
+
|
|
72
|
+
// reuse your buffer system
|
|
73
|
+
if (!this.writeBuffers.has(filePath)) {
|
|
74
|
+
this.writeBuffers.set(filePath, []);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.writeBuffers.get(filePath)!.push(line);
|
|
78
|
+
|
|
79
|
+
if (!this.flushTimers.has(filePath)) {
|
|
80
|
+
this.flushTimers.set(
|
|
81
|
+
filePath,
|
|
82
|
+
setTimeout(() => this.flushBuffer(filePath), 100)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
50
86
|
|
|
51
87
|
private async flushBuffer(filePath: string) {
|
|
52
88
|
const buffer = this.writeBuffers.get(filePath);
|
|
@@ -72,6 +108,32 @@ export class LogManager {
|
|
|
72
108
|
await this.flushBuffer(filePath);
|
|
73
109
|
}
|
|
74
110
|
}
|
|
111
|
+
|
|
112
|
+
private parseLine(line: string, level?: "err" | "out"): LogEntry {
|
|
113
|
+
|
|
114
|
+
let newLine: LogEntry;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
|
|
118
|
+
newLine = JSON.parse(line) as LogEntry;
|
|
119
|
+
|
|
120
|
+
} catch {
|
|
121
|
+
// fallback to old format
|
|
122
|
+
const ts = this.extractLogTs(line);
|
|
123
|
+
const msg = line.replace(`[${ts}]`, "").trim();
|
|
124
|
+
newLine = { ts, msg };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
newLine.msg = newLine.msg.replaceAll(nl, EOL)
|
|
128
|
+
newLine.level = level;
|
|
129
|
+
|
|
130
|
+
return newLine;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private extractLogTs(line: string) {
|
|
134
|
+
const match = line.match(isoRegex);
|
|
135
|
+
return match?.[0] ?? ""
|
|
136
|
+
}
|
|
75
137
|
|
|
76
138
|
async readLogs(
|
|
77
139
|
name: string,
|
|
@@ -79,135 +141,131 @@ export class LogManager {
|
|
|
79
141
|
lines: number = 20,
|
|
80
142
|
customOut?: string,
|
|
81
143
|
customErr?: string
|
|
82
|
-
): Promise<
|
|
83
|
-
const paths = this.getLogPaths(name, id, customOut, customErr);
|
|
84
|
-
let out = "";
|
|
85
|
-
let err = "";
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const outFile = Bun.file(paths.outFile);
|
|
89
|
-
if (await outFile.exists()) {
|
|
90
|
-
const text = await outFile.text();
|
|
91
|
-
out = text.split("\n").slice(-lines).join("\n");
|
|
92
|
-
}
|
|
93
|
-
} catch {}
|
|
144
|
+
): Promise<LogEntry[]> {
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} catch {}
|
|
146
|
+
const paths = this.getLogPaths(name, id, customOut, customErr);
|
|
147
|
+
|
|
148
|
+
const logs = (await Promise.all(Object.values(paths).map(async (fp) => {
|
|
149
|
+
|
|
150
|
+
const f = Bun.file(fp);
|
|
151
|
+
if (!(await f.exists())) return [];
|
|
102
152
|
|
|
103
|
-
|
|
153
|
+
const level = (fp == paths.errFile) ? "err" : "out";
|
|
154
|
+
|
|
155
|
+
const rawLog = await $`tail -n ${lines} ${fp}`.text();
|
|
156
|
+
|
|
157
|
+
return rawLog
|
|
158
|
+
.split("\n")
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map(l => this.parseLine(l, level));
|
|
161
|
+
|
|
162
|
+
}))).flat();
|
|
163
|
+
|
|
164
|
+
// lets sort the logs here
|
|
165
|
+
let sortedLogs = logs
|
|
166
|
+
.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""))
|
|
167
|
+
|
|
168
|
+
if (sortedLogs.length > lines) {
|
|
169
|
+
sortedLogs = sortedLogs.slice(-lines)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(sortedLogs)
|
|
173
|
+
|
|
174
|
+
return sortedLogs
|
|
104
175
|
}
|
|
105
176
|
|
|
106
177
|
async tailLog(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const f = Bun.file(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// which silently corrupts multi-byte sequences.
|
|
135
|
-
const buf = Buffer.allocUnsafe(byteLength);
|
|
136
|
-
const fd = openSync(filePath, "r");
|
|
137
|
-
try {
|
|
138
|
-
readSync(fd, buf, 0, byteLength, lastSize);
|
|
139
|
-
} finally {
|
|
140
|
-
closeSync(fd);
|
|
178
|
+
name: string,
|
|
179
|
+
id: number,
|
|
180
|
+
streamController: ReadableStreamDefaultController,
|
|
181
|
+
signal: AbortSignal
|
|
182
|
+
) {
|
|
183
|
+
const paths = this.getLogPaths(name, id);
|
|
184
|
+
|
|
185
|
+
const state = {
|
|
186
|
+
out: Bun.file(paths.outFile).size,
|
|
187
|
+
err: Bun.file(paths.errFile).size,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
const poll = setInterval(async () => {
|
|
192
|
+
for (const [type, fp] of [["out", paths.outFile],["err", paths.errFile],] as const) {
|
|
193
|
+
|
|
194
|
+
const f = Bun.file(fp);
|
|
195
|
+
|
|
196
|
+
if (!(await f.exists())) continue;
|
|
197
|
+
|
|
198
|
+
let lastSize = state[type];
|
|
199
|
+
|
|
200
|
+
const size = f.size;
|
|
201
|
+
|
|
202
|
+
if (size < lastSize) {
|
|
203
|
+
state[type] = 0; // rotated file
|
|
204
|
+
lastSize = 0;
|
|
141
205
|
}
|
|
142
|
-
|
|
143
|
-
lastSize
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
206
|
+
|
|
207
|
+
if (size === lastSize) continue;
|
|
208
|
+
|
|
209
|
+
const chunk = await f.slice(lastSize, size).text();
|
|
210
|
+
state[type] = size;
|
|
211
|
+
|
|
212
|
+
for (const line of chunk.split("\n").filter(Boolean)) {
|
|
213
|
+
try {
|
|
214
|
+
const log = { name, id, ...this.parseLine(line, type) };
|
|
215
|
+
streamController.enqueue(`data: ${JSON.stringify(log)}\n\n`);
|
|
216
|
+
} catch (e: any) {
|
|
217
|
+
console.log("tailLog: ", e, e.stack)
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
148
220
|
}
|
|
149
|
-
}
|
|
221
|
+
}
|
|
150
222
|
}, 500);
|
|
223
|
+
|
|
224
|
+
signal.addEventListener("abort", () => {
|
|
225
|
+
clearInterval(poll);
|
|
226
|
+
});
|
|
151
227
|
}
|
|
152
228
|
|
|
153
229
|
async rotate(filePath: string, options: LogRotateOptions): Promise<void> {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (!srcExists) continue;
|
|
169
|
-
|
|
230
|
+
|
|
231
|
+
const file = Bun.file(filePath);
|
|
232
|
+
|
|
233
|
+
if (!(await file.exists()) || file.size < options.maxSize) return;
|
|
234
|
+
|
|
235
|
+
const bgTasks: Promise<any>[] = [];
|
|
236
|
+
|
|
237
|
+
for (let i = options.retain - 1; i >= 1; i--) {
|
|
238
|
+
|
|
239
|
+
const src = i === 1 ? filePath : `${filePath}.${i - 1}`;
|
|
240
|
+
const dst = `${filePath}.${i}`;
|
|
241
|
+
|
|
242
|
+
if (await Bun.file(src).exists()) {
|
|
243
|
+
|
|
170
244
|
await rename(src, dst);
|
|
171
|
-
|
|
172
245
|
if (options.compress) {
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
// `dst` with `dst.gz` in-place, matching the old .gz naming.
|
|
176
|
-
try {
|
|
177
|
-
const proc = Bun.spawn(["gzip", "-f", dst], {
|
|
178
|
-
stdout: "ignore",
|
|
179
|
-
stderr: "pipe",
|
|
180
|
-
});
|
|
181
|
-
const exitCode = await proc.exited;
|
|
182
|
-
if (exitCode !== 0) {
|
|
183
|
-
const errText = await new Response(proc.stderr).text();
|
|
184
|
-
console.error(`[bm2] gzip failed for ${dst}: ${errText.trim()}`);
|
|
185
|
-
}
|
|
186
|
-
} catch (compressErr) {
|
|
187
|
-
console.error(`[bm2] Failed to compress rotated log ${dst}:`, compressErr);
|
|
188
|
-
}
|
|
246
|
+
// Fire-and-forget compression doesn't block the next rename
|
|
247
|
+
bgTasks.push(Bun.spawn(["gzip", "-f", dst]).exited);
|
|
189
248
|
}
|
|
190
249
|
}
|
|
191
|
-
|
|
192
|
-
// Clean excess rotated files asynchronously
|
|
193
|
-
const dir = dirname(filePath);
|
|
194
|
-
const baseName = filePath.split("/").pop()!;
|
|
195
|
-
try {
|
|
196
|
-
const files = await readdir(dir);
|
|
197
|
-
const rotated = files
|
|
198
|
-
.filter((f) => f.startsWith(baseName + "."))
|
|
199
|
-
.sort()
|
|
200
|
-
.reverse();
|
|
201
|
-
await Promise.all(
|
|
202
|
-
rotated.slice(options.retain).map((f) => unlink(join(dir, f)).catch(() => {}))
|
|
203
|
-
);
|
|
204
|
-
} catch {}
|
|
205
|
-
|
|
206
|
-
// Truncate original to reclaim inode while keeping it open for writers
|
|
207
|
-
await Bun.write(filePath, "");
|
|
208
|
-
} catch (err) {
|
|
209
|
-
console.error(`[bm2] Log rotation failed for ${filePath}:`, err);
|
|
210
250
|
}
|
|
251
|
+
|
|
252
|
+
await Bun.write(filePath, ""); // Instantly truncate and reclaim space
|
|
253
|
+
|
|
254
|
+
const dir = dirname(filePath);
|
|
255
|
+
const baseName = filePath.split("/").pop()!;
|
|
256
|
+
|
|
257
|
+
// Background cleanup
|
|
258
|
+
bgTasks.push(
|
|
259
|
+
readdir(dir).then(files =>
|
|
260
|
+
Promise.all(
|
|
261
|
+
files.filter(f => f.startsWith(`${baseName}.`)).sort().reverse()
|
|
262
|
+
.slice(options.retain).map(f => unlink(join(dir, f)).catch(() => {}))
|
|
263
|
+
)
|
|
264
|
+
).catch(() => {})
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Let Bun handle the heavy lifting in the background!
|
|
268
|
+
Promise.all(bgTasks).catch(() => {});
|
|
211
269
|
}
|
|
212
270
|
|
|
213
271
|
async flush(name: string, id: number, customOut?: string, customErr?: string) {
|
package/src/process-container.ts
CHANGED
|
@@ -153,11 +153,9 @@ export class ProcessContainer {
|
|
|
153
153
|
}
|
|
154
154
|
} catch (err: any) {
|
|
155
155
|
this.status = "errored";
|
|
156
|
-
|
|
157
|
-
await this.logManager.
|
|
158
|
-
|
|
159
|
-
`[${timestamp}] [bm2] Failed to start: ${err.message}\n`
|
|
160
|
-
);
|
|
156
|
+
|
|
157
|
+
await this.logManager.appendJSONLog(logPaths.errFile, `[bm2] Failed to start: ${err.message}`);
|
|
158
|
+
|
|
161
159
|
throw err;
|
|
162
160
|
}
|
|
163
161
|
}
|
|
@@ -229,6 +227,7 @@ export class ProcessContainer {
|
|
|
229
227
|
const reader = stream.getReader();
|
|
230
228
|
const decoder = new TextDecoder();
|
|
231
229
|
|
|
230
|
+
|
|
232
231
|
// Holds the tail of the last chunk if it did not end on a newline.
|
|
233
232
|
// Without this, a chunk boundary mid-word (e.g. "hel" / "lo\n") would be
|
|
234
233
|
// written as two separate log lines, corrupting the output.
|
|
@@ -237,12 +236,11 @@ export class ProcessContainer {
|
|
|
237
236
|
try {
|
|
238
237
|
while (true) {
|
|
239
238
|
const { done, value } = await reader.read();
|
|
240
|
-
|
|
239
|
+
|
|
241
240
|
if (done) {
|
|
242
241
|
// Flush any buffered content that was never terminated with \n
|
|
243
242
|
if (remainder.length > 0) {
|
|
244
|
-
|
|
245
|
-
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`);
|
|
243
|
+
await this.logManager.appendJSONLog(filePath, remainder)
|
|
246
244
|
remainder = "";
|
|
247
245
|
}
|
|
248
246
|
break;
|
|
@@ -255,27 +253,14 @@ export class ProcessContainer {
|
|
|
255
253
|
// Prepend any leftover from the previous chunk before splitting.
|
|
256
254
|
// This is a single string allocation per chunk (not per line), so
|
|
257
255
|
// allocation pressure stays O(chunk size) rather than O(line count).
|
|
258
|
-
const text = remainder + chunk;
|
|
259
|
-
const lines = text.split("\n");
|
|
260
|
-
|
|
261
|
-
// The last element is either "" (chunk ended on \n) or an incomplete
|
|
262
|
-
// line. Either way, hold it back for the next iteration.
|
|
263
|
-
remainder = lines.pop()!;
|
|
264
|
-
|
|
265
|
-
if (lines.length === 0) continue;
|
|
256
|
+
const text = (remainder + chunk).trim();
|
|
266
257
|
|
|
267
|
-
|
|
268
|
-
// Build a single string for all complete lines in this chunk so
|
|
269
|
-
// appendLog (and the underlying O_APPEND write) is called once per
|
|
270
|
-
// chunk, not once per line.
|
|
271
|
-
const output = lines.map((line) => `[${timestamp}] ${line}\n`).join("");
|
|
272
|
-
await this.logManager.appendLog(filePath, output);
|
|
258
|
+
await this.logManager.appendJSONLog(filePath, text);
|
|
273
259
|
}
|
|
274
260
|
} catch {
|
|
275
261
|
// Flush remainder on unexpected stream error
|
|
276
262
|
if (remainder.length > 0) {
|
|
277
|
-
|
|
278
|
-
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`).catch(() => {});
|
|
263
|
+
await this.logManager.appendJSONLog(filePath, remainder).catch(() => {});
|
|
279
264
|
}
|
|
280
265
|
}
|
|
281
266
|
}
|
package/src/process-manager.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
StartOptions,
|
|
20
20
|
EcosystemConfig,
|
|
21
21
|
MetricSnapshot,
|
|
22
|
+
LogEntry,
|
|
23
|
+
LogItem,
|
|
22
24
|
} from "./types";
|
|
23
25
|
import { ProcessContainer } from "./process-container";
|
|
24
26
|
import { LogManager } from "./log-manager";
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
DEFAULT_LOG_RETAIN,
|
|
38
40
|
} from "./constants";
|
|
39
41
|
import path from "path";
|
|
42
|
+
import type { ReadableStreamController } from "bun";
|
|
40
43
|
|
|
41
44
|
export class ProcessManager {
|
|
42
45
|
private processes: Map<number, ProcessContainer> = new Map();
|
|
@@ -306,15 +309,34 @@ import path from "path";
|
|
|
306
309
|
}
|
|
307
310
|
|
|
308
311
|
async getLogs(target: string | number, lines: number = 20) {
|
|
312
|
+
|
|
309
313
|
const containers = this.resolveTarget(target);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
314
|
+
|
|
315
|
+
// just for readability
|
|
316
|
+
let results: LogItem[] = [];
|
|
317
|
+
|
|
318
|
+
results = (await Promise.all(containers.map(async (c) => {
|
|
319
|
+
const logs = await this.logManager.readLogs(c.name, c.id, lines, c.config.outFile, c.config.errorFile);
|
|
320
|
+
return logs.map((log) => ({ name: c.name, id: c.id, ...log }))
|
|
321
|
+
}))).flat();
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
let sortedResults = results
|
|
325
|
+
.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
return sortedResults;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async streamLogs(target: string | number, streamController: ReadableStreamDefaultController, signal: AbortSignal) {
|
|
332
|
+
|
|
333
|
+
const containers = this.resolveTarget(target);
|
|
334
|
+
const lm = this.logManager;
|
|
335
|
+
|
|
336
|
+
await Promise.all(containers.map(async (c) => (
|
|
337
|
+
lm.tailLog(c.name, c.id, streamController, signal)
|
|
338
|
+
)))
|
|
339
|
+
|
|
318
340
|
}
|
|
319
341
|
|
|
320
342
|
async flushLogs(target?: string | number) {
|
|
@@ -401,6 +423,7 @@ import path from "path";
|
|
|
401
423
|
}
|
|
402
424
|
|
|
403
425
|
private resolveTarget(target: string | number): ProcessContainer[] {
|
|
426
|
+
|
|
404
427
|
if (target === "all") {
|
|
405
428
|
return Array.from(this.processes.values());
|
|
406
429
|
}
|
package/src/types.ts
CHANGED
|
@@ -177,6 +177,7 @@ export interface DaemonMessage {
|
|
|
177
177
|
type: string;
|
|
178
178
|
data?: any;
|
|
179
179
|
id?: string;
|
|
180
|
+
mode?: "stream" | "http"
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export interface DaemonResponse {
|
|
@@ -229,3 +230,16 @@ export interface DashboardState {
|
|
|
229
230
|
metrics: MetricSnapshot;
|
|
230
231
|
logs: Record<string, { out: string; err: string }>;
|
|
231
232
|
}
|
|
233
|
+
|
|
234
|
+
export type LogEntry = {
|
|
235
|
+
ts: string;
|
|
236
|
+
level?: "err" | "out",
|
|
237
|
+
msg: string;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export interface LogItem {
|
|
241
|
+
name: string;
|
|
242
|
+
id: number;
|
|
243
|
+
ts: string;
|
|
244
|
+
msg: string; level?: "err" | "out"
|
|
245
|
+
}[]
|