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 CHANGED
@@ -540,6 +540,8 @@ bm2 logs my-api --err
540
540
 
541
541
  ```
542
542
  bm2 logs my-api --follow
543
+ bm2 logs my-api -f
544
+ bm2 logs -f
543
545
  ```
544
546
 
545
547
  ---
package/package.json CHANGED
@@ -1,78 +1,80 @@
1
1
  {
2
- "name": "bm2",
3
- "version": "1.0.37",
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
- },
12
- "bin": {
13
- "bm2": "./src/index.ts"
14
- },
15
- "files": [
16
- "src/",
17
- "README.md",
18
- "LICENSE"
19
- ],
20
- "scripts": {
21
- "start": "bun run src/index.ts",
22
- "dev": "bun run --watch src/index.ts",
23
- "test": "bun test",
24
- "lint": "bun x tsc --noEmit",
25
- "prepublishOnly": "bun test"
26
- },
27
- "keywords": [
28
- "bun",
29
- "process-manager",
30
- "pm2",
31
- "pm2-alternative",
32
- "cluster",
33
- "daemon",
34
- "production",
35
- "deployment",
36
- "monitoring",
37
- "prometheus",
38
- "zero-downtime",
39
- "reload",
40
- "log-management",
41
- "health-check",
42
- "bm2"
43
- ],
44
- "author": {
45
- "name": "MaxxPainn",
46
- "email": "hello@maxxpainn.com",
47
- "url": "https://maxxpainn.com"
48
- },
49
- "license": "GPL-3.0",
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/bun-bm2/bm2.git"
53
- },
54
- "bugs": {
55
- "url": "https://github.com/bun-bm2/bm2/issues",
56
- "email": "hello@maxxpainn.com"
57
- },
58
- "homepage": "https://github.com/bun-bm2/bm2#readme",
59
- "engines": {
60
- "bun": ">=1.0.0"
61
- },
62
- "os": [
63
- "linux",
64
- "darwin"
65
- ],
66
- "dependencies": {
67
- "cli-table3": "^0.6.5",
68
- "pidusage": "^4.0.1",
69
- "ws": "^8.19.0"
70
- },
71
- "devDependencies": {
72
- "@types/bun": "^1.3.9",
73
- "@types/pidusage": "^2.0.5",
74
- "@types/ws": "^8.18.1",
75
- "bun-types": "latest",
76
- "typescript": "^5.9.3"
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
- const target = args[0] || "all";
625
+
626
+ let target: string | number = "all";
571
627
  let lines = 20;
572
- const linesIdx = args.indexOf("--lines");
573
- if (linesIdx !== -1 && args[linesIdx + 1]) {
574
- lines = parseInt(args[linesIdx + 1]!);
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 res = await this.sendToDaemon({ type: "logs", data: { target, lines } });
578
- if (!res.success) {
579
- console.error(colorize(`Error: ${res.error}`, "red"));
580
- process.exit(1);
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
- for (const log of res.data) {
584
- console.log(colorize(`\n─── ${log.name} (id: ${log.id}) ───`, "bold"));
585
- if (log.out) {
586
- console.log(colorize("--- stdout ---", "dim"));
587
- console.log(log.out);
658
+
659
+ if (follow) {
660
+
661
+ const opts: DaemonMessage = {
662
+ type: "streamLogs",
663
+ data: { target },
664
+ mode: "stream"
588
665
  }
589
- if (log.err) {
590
- console.log(colorize("--- stderr ---", "red"));
591
- console.log(log.err);
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
 
@@ -15,12 +15,21 @@
15
15
  */
16
16
 
17
17
  import { join, dirname } from "path";
18
- import { openSync, readSync, closeSync } from "fs";
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<{ out: string; err: string }> {
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
- try {
96
- const errFile = Bun.file(paths.errFile);
97
- if (await errFile.exists()) {
98
- const text = await errFile.text();
99
- err = text.split("\n").slice(-lines).join("\n");
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
- return { out, err };
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
- filePath: string,
108
- callback: (line: string) => void,
109
- signal?: AbortSignal
110
- ): Promise<void> {
111
- let lastSize = 0;
112
- const file = Bun.file(filePath);
113
- if (await file.exists()) {
114
- lastSize = file.size;
115
- }
116
-
117
- const interval = setInterval(async () => {
118
- if (signal?.aborted) {
119
- clearInterval(interval);
120
- return;
121
- }
122
- try {
123
- const f = Bun.file(filePath);
124
- if (!(await f.exists())) return;
125
-
126
- const currentSize = f.size;
127
- if (currentSize <= lastSize) return;
128
-
129
- const byteLength = currentSize - lastSize;
130
-
131
- // Read only the new bytes via fs.readSync to avoid:
132
- // 1. Loading the entire file into memory on every poll.
133
- // 2. Slicing by character offset (lastSize) on a UTF-8 string,
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 = currentSize;
144
-
145
- const newContent = new TextDecoder().decode(buf);
146
- for (const line of newContent.split("\n").filter(Boolean)) {
147
- callback(line);
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
- } catch {}
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
- try {
155
- const file = Bun.file(filePath);
156
- if (!(await file.exists())) return;
157
-
158
- // Async stat — no thread-blocking syscall on the main event loop
159
- const fileStat = await stat(filePath);
160
- if (fileStat.size < options.maxSize) return;
161
-
162
- // Rotate files: shift .N → .N+1, filePath → .1
163
- for (let i = options.retain - 1; i >= 1; i--) {
164
- const src = i === 1 ? filePath : `${filePath}.${i - 1}`;
165
- const dst = `${filePath}.${i}`;
166
-
167
- const srcExists = await access(src).then(() => true).catch(() => false);
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
- // Spawn the system `gzip` binary as a background subprocess so
174
- // compression never blocks the JS event loop. gzip -f replaces
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) {
@@ -153,11 +153,9 @@ export class ProcessContainer {
153
153
  }
154
154
  } catch (err: any) {
155
155
  this.status = "errored";
156
- const timestamp = new Date().toISOString();
157
- await this.logManager.appendLog(
158
- logPaths.errFile,
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
- const timestamp = new Date().toISOString();
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
- const timestamp = new Date().toISOString();
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
- const timestamp = new Date().toISOString();
278
- await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`).catch(() => {});
263
+ await this.logManager.appendJSONLog(filePath, remainder).catch(() => {});
279
264
  }
280
265
  }
281
266
  }
@@ -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
- const results: Array<{ name: string; id: number; out: string; err: string }> = [];
311
- for (const c of containers) {
312
- const logs = await this.logManager.readLogs(
313
- c.name, c.id, lines, c.config.outFile, c.config.errorFile
314
- );
315
- results.push({ name: c.name, id: c.id, ...logs });
316
- }
317
- return results;
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
+ }[]