bm2 1.0.36 → 1.0.38

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
@@ -135,7 +135,7 @@ curl -fsSL https://bun.sh/install | bash
135
135
  ### From Source
136
136
 
137
137
  ```
138
- git clone https://github.com/aspect-dev/bm2.git
138
+ git clone https://github.com/bun-bm2/bm2.git
139
139
  cd bm2
140
140
  bun install
141
141
  bun link
@@ -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.36",
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.38",
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 {
@@ -100,6 +100,11 @@ export default class Daemon {
100
100
  try {
101
101
 
102
102
  const msg = (await req.json()) as DaemonMessage;
103
+
104
+ if (msg.mode == "stream") {
105
+ return this.handleStream(msg, req);
106
+ }
107
+
103
108
  const response = await this.handleMessage(msg);
104
109
  return Response.json(response);
105
110
 
@@ -110,6 +115,33 @@ export default class Daemon {
110
115
  );
111
116
  }
112
117
  }
118
+
119
+ handleStream(msg: DaemonMessage, req: Request) {
120
+
121
+ //let controller: ReadableStreamDefaultController;
122
+ const self = this;
123
+ const signal: AbortSignal = req.signal;
124
+
125
+ const stream = new ReadableStream({
126
+ start(controller) {
127
+
128
+ self.handleStreamMessage(msg, controller, signal);
129
+
130
+ // cleanup when client disconnects
131
+ signal.addEventListener("abort", () => {
132
+ controller.close();
133
+ });
134
+ },
135
+ });
136
+
137
+ return new Response(stream, {
138
+ headers: {
139
+ "Content-Type": "text/event-stream", // SSE style
140
+ "Cache-Control": "no-cache",
141
+ Connection: "keep-alive",
142
+ },
143
+ });
144
+ }
113
145
 
114
146
  // initialize MUST be called before startServer
115
147
  startServer(): Server<any> {
@@ -121,6 +153,27 @@ export default class Daemon {
121
153
  this.server = Bun.serve(this.getServerOpts());
122
154
  return this.server;
123
155
  }
156
+
157
+ async handleStreamMessage(msg: DaemonMessage, streamController: ReadableStreamDefaultController, signal: AbortSignal) {
158
+
159
+ if (!this.initialized) {
160
+ await this.initialize();
161
+ }
162
+
163
+ const pm = this.pm!;
164
+ //const dashboard = this.dashboard!;
165
+ //const moduleManager = this.moduleManager!;
166
+ //const metricsInterval = this.metricsInterval!;
167
+
168
+ switch (msg.type) {
169
+ case "streamLogs": {
170
+ await pm.streamLogs(msg.data.target, streamController, signal);
171
+ break;
172
+ }
173
+ default:
174
+ }
175
+ }
176
+
124
177
 
125
178
  async handleMessage(msg: DaemonMessage): Promise<DaemonResponse> {
126
179
  try {
@@ -185,6 +238,7 @@ export default class Daemon {
185
238
  const logs = await pm.getLogs(msg.data.target, msg.data.lines);
186
239
  return { type: "logs", data: logs, success: true, id: msg.id };
187
240
  }
241
+
188
242
  case "flush": {
189
243
  await pm.flushLogs(msg.data?.target);
190
244
  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
@@ -53,6 +56,8 @@ await ensureDirs();
53
56
  // ---------------------------------------------------------------------------
54
57
 
55
58
  class BM2CLI {
59
+
60
+ noDaemon = false;
56
61
 
57
62
  // -------------------------------------------------------------------------
58
63
  // Daemon helpers
@@ -120,6 +125,12 @@ class BM2CLI {
120
125
  }
121
126
 
122
127
  async sendToDaemon(msg: DaemonMessage): Promise<DaemonResponse> {
128
+
129
+
130
+ if (this.noDaemon) {
131
+ return this.callDaemonCmd(msg)
132
+ }
133
+
123
134
  await this.startDaemon();
124
135
 
125
136
  let res;
@@ -155,6 +166,57 @@ class BM2CLI {
155
166
  return { type: "error", error: "Fetch Error", success: false };
156
167
  }
157
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
+ } catch (e: any) {
217
+ console.log("getDaemonStream:", e, e.stack);
218
+ }
219
+ }
158
220
 
159
221
  // -------------------------------------------------------------------------
160
222
  // Ecosystem config loader
@@ -180,9 +242,15 @@ class BM2CLI {
180
242
  }
181
243
 
182
244
  const cwd = path.dirname(abs);
245
+
246
+ if (config.noDaemon) {
247
+ this.noDaemon = config.noDaemon;
248
+ }
183
249
 
184
250
  config.apps = config.apps.map((i) => {
185
- if ((i.cwd || "").trim() === "") i.cwd = cwd;
251
+ if ((i.cwd || "").trim() === "") {
252
+ i.cwd = cwd;
253
+ }
186
254
  return i;
187
255
  });
188
256
 
@@ -202,7 +270,8 @@ class BM2CLI {
202
270
  // -------------------------------------------------------------------------
203
271
 
204
272
  parseStartFlags(args: string[]): StartOptions {
205
- const opts: StartOptions = { script: "", noDaemon: false };
273
+
274
+ const opts: StartOptions = { script: "" };
206
275
 
207
276
  let i = 0;
208
277
  let scriptResolved = false;
@@ -275,10 +344,6 @@ class BM2CLI {
275
344
  case "--no-autorestart":
276
345
  opts.autorestart = false;
277
346
  break;
278
- case "--no-daemon":
279
- case "-d":
280
- opts.noDaemon = true;
281
- break;
282
347
  case "--env": {
283
348
  const envPair = args[++i]!;
284
349
  const eqIdx = envPair.indexOf("=");
@@ -369,6 +434,7 @@ class BM2CLI {
369
434
  // -------------------------------------------------------------------------
370
435
 
371
436
  async cmdStart(args: string[]) {
437
+
372
438
  if (args.length === 0) {
373
439
  console.error(colorize("Usage: bm2 start <script|config> [options]", "red"));
374
440
  process.exit(1);
@@ -391,42 +457,47 @@ class BM2CLI {
391
457
  firstPositional.includes("bm2.config") ||
392
458
  firstPositional.includes("pm2.config")
393
459
  ) {
460
+
394
461
  const config = await this.loadEcosystemConfig(firstPositional);
395
462
  const res = await this.sendToDaemon({ type: "ecosystem", data: config });
463
+
396
464
  if (!res.success) {
397
465
  console.error(colorize(`Error: ${res.error}`, "red"));
398
466
  process.exit(1);
399
467
  }
468
+
400
469
  printProcessTable(res.data);
401
- return;
402
- }
403
-
404
- // Parse all args — parseStartFlags finds the script itself
405
- const opts = this.parseStartFlags(args);
406
-
407
- if (!opts.script) {
408
- console.error(colorize("Error: no script specified", "red"));
409
- process.exit(1);
410
- }
411
-
412
- opts.script = resolve(opts.script);
413
- if (!opts.cwd) opts.cwd = path.dirname(opts.script);
414
-
415
- const noDaemon = opts.noDaemon;
416
-
417
- const res = noDaemon
418
- ? await this.callDaemonCmd({ type: "start", data: opts })
419
- : await this.sendToDaemon({ type: "start", data: opts });
420
-
421
- if (!res.success) {
422
- console.error(colorize(`Error: ${res.error}`, "red"));
423
- process.exit(1);
424
- }
425
-
426
- printProcessTable(res.data);
470
+
471
+ if (this.noDaemon) {
472
+ await new Promise(() => {});
473
+ }
474
+
475
+ } else {
427
476
 
428
- if (noDaemon) {
429
- await new Promise(() => {});
477
+ // Parse all args — parseStartFlags finds the script itself
478
+ const opts = this.parseStartFlags(args);
479
+
480
+ if (!opts.script) {
481
+ console.error(colorize("Error: no script specified", "red"));
482
+ process.exit(1);
483
+ }
484
+
485
+ opts.script = resolve(opts.script);
486
+
487
+ if (!opts.cwd) opts.cwd = path.dirname(opts.script);
488
+
489
+ const res = await this.sendToDaemon({ type: "start", data: opts });
490
+
491
+ if (!res.success) {
492
+ console.error(colorize(`Error: ${res.error}`, "red"));
493
+ process.exit(1);
494
+ }
495
+
496
+ printProcessTable(res.data);
497
+
498
+ if (this.noDaemon) {
499
+ await new Promise(() => {});
500
+ }
430
501
  }
431
502
  }
432
503
 
@@ -548,31 +619,74 @@ class BM2CLI {
548
619
  console.log();
549
620
  }
550
621
  }
551
-
622
+
552
623
  async cmdLogs(args: string[]) {
553
- const target = args[0] || "all";
624
+
625
+ let target: string | number = "all";
554
626
  let lines = 20;
555
- const linesIdx = args.indexOf("--lines");
556
- if (linesIdx !== -1 && args[linesIdx + 1]) {
557
- lines = parseInt(args[linesIdx + 1]!);
627
+ let follow = false;
628
+
629
+ let i = 0;
630
+
631
+ while (i < args.length) {
632
+ const arg = args[i]!;
633
+
634
+ if ((arg === "--lines" || arg === "-l") && !Number.isNaN(Number(args[i + 1]))) {
635
+ lines = parseInt(args[i + 1]!);
636
+ i++; // skip value
637
+ } else if (arg.startsWith("--lines=")) {
638
+ lines = parseInt(arg.split("=")[1]!);
639
+ } else if (arg === "--follow" || arg === "-f") {
640
+ follow = true;
641
+ } else if (!arg.startsWith("-")) {
642
+ target = arg;
643
+ }
644
+
645
+ i++;
558
646
  }
559
-
560
- const res = await this.sendToDaemon({ type: "logs", data: { target, lines } });
561
- if (!res.success) {
562
- console.error(colorize(`Error: ${res.error}`, "red"));
563
- process.exit(1);
647
+
648
+ const renderLog = (log: LogItem) => {
649
+ let line;
650
+ if (log.level == "err") {
651
+ line = chalk.red(`[ERROR] ${log.name} | ${log.ts}: ${log.msg}\n`)
652
+ } else {
653
+ line = chalk.white(`${chalk.cyan(`[OUTPUT] ${log.name} | ${log.ts}`)}: ${log.msg}\n`)
654
+ }
655
+ console.log(line)
564
656
  }
565
-
566
- for (const log of res.data) {
567
- console.log(colorize(`\n─── ${log.name} (id: ${log.id}) ───`, "bold"));
568
- if (log.out) {
569
- console.log(colorize("--- stdout ---", "dim"));
570
- console.log(log.out);
657
+
658
+ if (follow) {
659
+
660
+ const opts: DaemonMessage = {
661
+ type: "streamLogs",
662
+ data: { target },
663
+ mode: "stream"
571
664
  }
572
- if (log.err) {
573
- console.log(colorize("--- stderr ---", "red"));
574
- console.log(log.err);
665
+
666
+ const callback = (log: LogItem | null) => {
667
+ if(log) renderLog(log)
575
668
  }
669
+
670
+ await this.getDaemonStream<LogItem>(opts, callback);
671
+
672
+ } else {
673
+
674
+ const res = await this.sendToDaemon({
675
+ type: "logs",
676
+ data: { target, lines },
677
+ });
678
+
679
+ if (!res.success) {
680
+ console.error(colorize(`Error: ${res.error}`, "red"));
681
+ process.exit(1);
682
+ }
683
+
684
+ const logs: LogItem[] = res.data ?? [];
685
+
686
+ for (let log of logs) {
687
+ renderLog(log)
688
+ }
689
+
576
690
  }
577
691
  }
578
692
 
@@ -1044,6 +1158,8 @@ class BM2CLI {
1044
1158
  async run(argv: string[]) {
1045
1159
  const command = argv[0];
1046
1160
  const commandArgs = argv.slice(1);
1161
+
1162
+ this.noDaemon = argv.includes("--no-daemon") || argv.includes("-d");
1047
1163
 
1048
1164
  switch (command) {
1049
1165
  case "start":
@@ -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,125 @@ 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
+ const poll = setInterval(async () => {
191
+ for (const [type, fp] of [["out", paths.outFile],["err", paths.errFile],] as const) {
192
+
193
+ const f = Bun.file(fp);
194
+
195
+ if (!(await f.exists())) continue;
196
+
197
+ let lastSize = state[type];
198
+
199
+ const size = f.size;
200
+
201
+ if (size < lastSize) {
202
+ state[type] = 0; // rotated file
203
+ lastSize = 0;
141
204
  }
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);
205
+
206
+ if (size === lastSize) continue;
207
+
208
+ const chunk = await f.slice(lastSize, size).text();
209
+ state[type] = size;
210
+
211
+ for (const line of chunk.split("\n").filter(Boolean)) {
212
+ const log = { name, id, ...this.parseLine(line, type) };
213
+ streamController.enqueue(`data: ${JSON.stringify(log)}\n\n`);
148
214
  }
149
- } catch {}
215
+ }
150
216
  }, 500);
217
+
218
+ signal.addEventListener("abort", () => {
219
+ clearInterval(poll);
220
+ });
151
221
  }
152
222
 
153
223
  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
-
224
+
225
+ const file = Bun.file(filePath);
226
+
227
+ if (!(await file.exists()) || file.size < options.maxSize) return;
228
+
229
+ const bgTasks: Promise<any>[] = [];
230
+
231
+ for (let i = options.retain - 1; i >= 1; i--) {
232
+
233
+ const src = i === 1 ? filePath : `${filePath}.${i - 1}`;
234
+ const dst = `${filePath}.${i}`;
235
+
236
+ if (await Bun.file(src).exists()) {
237
+
170
238
  await rename(src, dst);
171
-
172
239
  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
- }
240
+ // Fire-and-forget compression doesn't block the next rename
241
+ bgTasks.push(Bun.spawn(["gzip", "-f", dst]).exited);
189
242
  }
190
243
  }
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
244
  }
245
+
246
+ await Bun.write(filePath, ""); // Instantly truncate and reclaim space
247
+
248
+ const dir = dirname(filePath);
249
+ const baseName = filePath.split("/").pop()!;
250
+
251
+ // Background cleanup
252
+ bgTasks.push(
253
+ readdir(dir).then(files =>
254
+ Promise.all(
255
+ files.filter(f => f.startsWith(`${baseName}.`)).sort().reverse()
256
+ .slice(options.retain).map(f => unlink(join(dir, f)).catch(() => {}))
257
+ )
258
+ ).catch(() => {})
259
+ );
260
+
261
+ // Let Bun handle the heavy lifting in the background!
262
+ Promise.all(bgTasks).catch(() => {});
211
263
  }
212
264
 
213
265
  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
@@ -124,7 +124,6 @@ export interface StartOptions {
124
124
  instances?: number;
125
125
  execMode?: ExecMode;
126
126
  autorestart?: boolean;
127
- noDaemon?: boolean;
128
127
  maxRestarts?: number;
129
128
  minUptime?: number;
130
129
  maxMemoryRestart?: string | number;
@@ -156,6 +155,7 @@ export interface StartOptions {
156
155
 
157
156
  export interface EcosystemConfig {
158
157
  apps: StartOptions[];
158
+ noDaemon?: boolean;
159
159
  deploy?: Record<string, DeployConfig>;
160
160
  }
161
161
 
@@ -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
+ }[]