bgrun 3.4.0 → 3.7.1

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
@@ -1,8 +1,8 @@
1
1
  <div align="center">
2
2
 
3
- # bgrun
3
+ <img src="./image.png" alt="bgrun" width="600" />
4
4
 
5
- **Bun Background Runner a modern process manager built on Bun**
5
+ **Production-ready process manager with dashboard and programmatic API, designed for running your containers, services, and AI agents.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/bgrun?color=F7A41D&label=npm&logo=npm)](https://www.npmjs.com/package/bgrun)
8
8
  [![bun](https://img.shields.io/badge/runtime-bun-F7A41D?logo=bun)](https://bun.sh/)
@@ -58,6 +58,29 @@ That's it. bgrun tracks the PID, captures stdout/stderr, detects the port, and s
58
58
 
59
59
  ---
60
60
 
61
+ ## 📊 Web Dashboard
62
+
63
+
64
+ Launch with `bgrun --dashboard` and open `http://localhost:3001`. Processes are auto-grouped by working directory.
65
+
66
+ **Expose with Caddy** for remote access:
67
+
68
+ ```
69
+ bgrun.yourdomain.com {
70
+ reverse_proxy localhost:3001
71
+ }
72
+ ```
73
+
74
+ Features:
75
+ - Real-time process status via SSE (no polling)
76
+ - Start, stop, restart, and delete processes from the UI
77
+ - Live stdout/stderr log viewer with search
78
+ - Memory, PID, port, and runtime at a glance
79
+ - Responsive mobile layout
80
+ - Collapsible directory groups
81
+
82
+ ---
83
+
61
84
  ## Table of Contents
62
85
 
63
86
  - [Core Commands](#core-commands)
@@ -697,6 +720,6 @@ MIT
697
720
 
698
721
  <div align="center">
699
722
 
700
- Built by [Mements](https://github.com/Mements) with ⚡ Bun
723
+ Built with ⚡ Bun
701
724
 
702
725
  </div>
@@ -0,0 +1,55 @@
1
+ /**
2
+ * GET /api/config/:name — Read .config.toml content
3
+ * PUT /api/config/:name — Write .config.toml content
4
+ */
5
+ import { getProcess } from '../../../../../src/db';
6
+ import { join } from 'path';
7
+ import { readFile, writeFile } from 'fs/promises';
8
+
9
+ function resolveConfigPath(proc: any): string | null {
10
+ if (!proc.configPath) return null;
11
+ return join(proc.workdir, proc.configPath);
12
+ }
13
+
14
+ export async function GET(req: Request, { params }: { params: { name: string } }) {
15
+ const name = decodeURIComponent(params.name);
16
+ const proc = getProcess(name);
17
+
18
+ if (!proc) {
19
+ return Response.json({ error: 'Process not found' }, { status: 404 });
20
+ }
21
+
22
+ const configPath = resolveConfigPath(proc);
23
+ if (!configPath) {
24
+ return Response.json({ content: '', path: null, exists: false });
25
+ }
26
+
27
+ try {
28
+ const content = await readFile(configPath, 'utf-8');
29
+ return Response.json({ content, path: configPath, exists: true });
30
+ } catch {
31
+ return Response.json({ content: '', path: configPath, exists: false });
32
+ }
33
+ }
34
+
35
+ export async function PUT(req: Request, { params }: { params: { name: string } }) {
36
+ const name = decodeURIComponent(params.name);
37
+ const proc = getProcess(name);
38
+
39
+ if (!proc) {
40
+ return Response.json({ error: 'Process not found' }, { status: 404 });
41
+ }
42
+
43
+ const configPath = resolveConfigPath(proc);
44
+ if (!configPath) {
45
+ return Response.json({ error: 'No config path configured' }, { status: 400 });
46
+ }
47
+
48
+ try {
49
+ const body = await req.json();
50
+ await writeFile(configPath, body.content, 'utf-8');
51
+ return Response.json({ success: true, path: configPath });
52
+ } catch (e: any) {
53
+ return Response.json({ error: e.message }, { status: 500 });
54
+ }
55
+ }
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Returns DB path, BGR home dir, platform info for diagnostics.
5
5
  */
6
- import { getDbInfo } from 'bgrun';
6
+ import { getDbInfo } from '../../../../src/db';
7
+ import { measureSync } from 'measure-fn';
7
8
 
8
9
  export async function GET() {
9
- const info = getDbInfo();
10
+ const info = measureSync('DB info', () => getDbInfo());
10
11
  return Response.json({
11
12
  ...info,
12
13
  platform: process.platform,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * GET /api/events — Server-Sent Events endpoint
3
+ *
4
+ * Streams process data every 3 seconds. Replaces client-side polling.
5
+ * The client connects once via EventSource and receives updates automatically.
6
+ *
7
+ * Delegates to /api/processes for data enrichment (including PID reconciliation).
8
+ */
9
+
10
+ const INTERVAL_MS = 3_000;
11
+
12
+ export async function GET(req: Request) {
13
+ const url = new URL(req.url);
14
+ const origin = url.origin; // e.g. http://localhost:3001
15
+
16
+ const encoder = new TextEncoder();
17
+
18
+ const stream = new ReadableStream({
19
+ async start(controller) {
20
+ // Send initial data immediately
21
+ try {
22
+ const res = await fetch(`${origin}/api/processes?t=${Date.now()}`);
23
+ const data = await res.json();
24
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
25
+ } catch {
26
+ controller.enqueue(encoder.encode(`data: []\n\n`));
27
+ }
28
+
29
+ controller.enqueue(encoder.encode(`: keepalive\n\n`));
30
+
31
+ // Then send updates every INTERVAL_MS
32
+ const interval = setInterval(async () => {
33
+ try {
34
+ const res = await fetch(`${origin}/api/processes?t=${Date.now()}`);
35
+ const data = await res.json();
36
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
37
+ } catch {
38
+ // Skip this tick, send on next
39
+ }
40
+ }, INTERVAL_MS);
41
+
42
+ // Store cleanup for when the stream is cancelled
43
+ (stream as any).__cleanup = () => clearInterval(interval);
44
+ },
45
+ cancel() {
46
+ if ((stream as any).__cleanup) {
47
+ (stream as any).__cleanup();
48
+ }
49
+ }
50
+ });
51
+
52
+ return new Response(stream, {
53
+ headers: {
54
+ 'Content-Type': 'text/event-stream',
55
+ 'Cache-Control': 'no-cache',
56
+ 'Connection': 'keep-alive',
57
+ 'X-Accel-Buffering': 'no',
58
+ },
59
+ });
60
+ }
@@ -1,7 +1,52 @@
1
1
  /**
2
- * GET /api/logs/:name — Read last 100 lines of process stdout/stderr
2
+ * GET /api/logs/:name — Read process stdout/stderr logs
3
+ *
4
+ * Supports incremental loading via query params:
5
+ * ?tab=stdout|stderr — which log to read (default: stdout)
6
+ * ?offset=N — byte offset to start reading from (default: 0 = full file)
7
+ *
8
+ * Returns:
9
+ * { text, size, mtime, filePath }
10
+ *
11
+ * On first call (offset=0), returns full file content.
12
+ * On subsequent calls (offset=previousSize), returns only new bytes.
13
+ * Client uses `size` as the offset for the next request.
3
14
  */
4
- import { getProcess, readFileTail } from 'bgrun';
15
+ import { getProcess } from '../../../../../src/db';
16
+ import { stat, open } from 'fs/promises';
17
+
18
+ interface FileInfo {
19
+ text: string;
20
+ size: number;
21
+ mtime: string | null;
22
+ filePath: string;
23
+ }
24
+
25
+ async function readLogFile(path: string, offset: number): Promise<FileInfo> {
26
+ try {
27
+ const s = await stat(path);
28
+ const size = s.size;
29
+ const mtime = s.mtime.toISOString();
30
+
31
+ // If offset >= current size, no new data
32
+ if (offset >= size) {
33
+ return { text: '', size, mtime, filePath: path };
34
+ }
35
+
36
+ // Read from offset to end
37
+ const handle = await open(path, 'r');
38
+ try {
39
+ const bytesToRead = size - offset;
40
+ const buffer = Buffer.alloc(bytesToRead);
41
+ await handle.read(buffer, 0, bytesToRead, offset);
42
+ return { text: buffer.toString('utf-8'), size, mtime, filePath: path };
43
+ } finally {
44
+ await handle.close();
45
+ }
46
+ } catch {
47
+ return { text: '', size: 0, mtime: null, filePath: path };
48
+ }
49
+ }
5
50
 
6
51
  export async function GET(req: Request, { params }: { params: { name: string } }) {
7
52
  const name = decodeURIComponent(params.name);
@@ -11,7 +56,12 @@ export async function GET(req: Request, { params }: { params: { name: string } }
11
56
  return Response.json({ error: 'Process not found' }, { status: 404 });
12
57
  }
13
58
 
14
- const stdout = await readFileTail(proc.stdout_path, 100);
15
- const stderr = await readFileTail(proc.stderr_path, 100);
16
- return Response.json({ stdout, stderr });
59
+ const url = new URL(req.url);
60
+ const tab = url.searchParams.get('tab') || 'stdout';
61
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10) || 0;
62
+
63
+ const path = tab === 'stderr' ? proc.stderr_path : proc.stdout_path;
64
+ const info = await readLogFile(path, offset);
65
+
66
+ return Response.json(info);
17
67
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * DELETE /api/processes/:name — Stop and remove a process
3
3
  */
4
- import { getProcess, removeProcessByName, isProcessRunning, terminateProcess } from 'bgrun';
4
+ import { getProcess, removeProcessByName } from '../../../../../src/db';
5
+ import { isProcessRunning, terminateProcess } from '../../../../../src/platform';
6
+ import { measure } from 'measure-fn';
5
7
 
6
8
  export async function DELETE(req: Request, { params }: { params: { name: string } }) {
7
9
  const name = decodeURIComponent(params.name);
@@ -12,8 +14,9 @@ export async function DELETE(req: Request, { params }: { params: { name: string
12
14
  }
13
15
 
14
16
  if (await isProcessRunning(proc.pid)) {
15
- await terminateProcess(proc.pid);
17
+ await measure(`Terminate "${name}" before delete`, () => terminateProcess(proc.pid));
16
18
  }
19
+
17
20
  removeProcessByName(name);
18
21
  return Response.json({ success: true });
19
22
  }
@@ -1,13 +1,11 @@
1
- /**
2
- * GET /api/processes Enriched process list
3
- *
4
- * Uses batch subprocess calls (single tasklist + single netstat)
5
- * instead of per-process calls to avoid subprocess pile-up on Windows.
6
- * Results are cached for 5 seconds via globalThis.
7
- */
8
- import { getAllProcesses, calculateRuntime, getProcessBatchMemory, getDbInfo } from 'bgrun';
1
+ import { getAllProcesses, updateProcessPid } from '../../../../src/db';
2
+ import { calculateRuntime } from '../../../../src/utils';
3
+ import { getProcessBatchMemory, reconcileProcessPids } from '../../../../src/platform';
4
+ import { measure, createMeasure } from 'measure-fn';
9
5
  import { $ } from 'bun';
10
6
 
7
+ const api = createMeasure('api');
8
+
11
9
  const CACHE_TTL_MS = 5_000;
12
10
  const SUBPROCESS_TIMEOUT_MS = 4_000;
13
11
 
@@ -105,46 +103,87 @@ function getProcessGroup(envStr: string): string | null {
105
103
  return match ? match[1] : null;
106
104
  }
107
105
 
108
- // ... existing code ...
109
-
110
106
  async function fetchProcesses(): Promise<any[]> {
111
- const procs = getAllProcesses();
112
- const pids = procs.map((p: any) => p.pid);
113
-
114
- // Three subprocess calls total (not 3×N)
115
- const [runningPids, portMap, memoryMap] = await Promise.all([
116
- withTimeout(getRunningPids(pids), new Set<number>()),
117
- withTimeout(getPortsByPid(pids), new Map<number, number[]>()),
118
- // Use the new batch memory fetcher
119
- withTimeout(getProcessBatchMemory(pids), new Map<number, number>()),
120
- ]);
107
+ return await api.measure('Fetch processes', async (m) => {
108
+ const procs = getAllProcesses();
109
+ const pids = procs.map((p: any) => p.pid);
110
+
111
+ // Three subprocess calls total (not 3×N)
112
+ let [runningPids, portMap, memoryMap] = await Promise.all([
113
+ m('Running PIDs', () => withTimeout(getRunningPids(pids), new Set<number>())),
114
+ m('Port map', () => withTimeout(getPortsByPid(pids), new Map<number, number[]>())),
115
+ m('Memory map', () => withTimeout(getProcessBatchMemory(pids), new Map<number, number>())),
116
+ ]);
117
+
118
+ // PID reconciliation: if stored PIDs are dead, try to find the real process
119
+ const allPids = new Set(pids);
120
+ const deadPids = new Set(pids.filter((pid: number) => !runningPids?.has(pid)));
121
+
122
+ if (deadPids.size > 0) {
123
+ const reconciled = await m('Reconcile dead PIDs', () =>
124
+ withTimeout(reconcileProcessPids(procs, deadPids), new Map<string, number>())
125
+ );
126
+
127
+ if (reconciled && reconciled.size > 0) {
128
+ // Update stored PIDs in DB and refresh running status
129
+ const newPids: number[] = [];
130
+ for (const [name, newPid] of reconciled) {
131
+ updateProcessPid(name, newPid);
132
+ newPids.push(newPid);
133
+ // Update the proc object in-place so the response uses the new PID
134
+ const proc = procs.find((p: any) => p.name === name);
135
+ if (proc) proc.pid = newPid;
136
+ }
121
137
 
122
- return procs.map((p: any) => {
123
- const running = runningPids.has(p.pid);
124
- const ports = running ? (portMap.get(p.pid) || []) : [];
125
- const memory = running ? (memoryMap.get(p.pid) || 0) : 0;
126
-
127
- return {
128
- name: p.name,
129
- command: p.command,
130
- directory: p.workdir,
131
- pid: p.pid,
132
- running,
133
- port: ports.length > 0 ? ports[0] : null,
134
- ports,
135
- memory, // Bytes
136
- group: getProcessGroup(p.env),
137
- runtime: calculateRuntime(p.timestamp),
138
- timestamp: p.timestamp,
139
- };
140
- });
138
+ // Mark reconciled PIDs as running
139
+ if (!runningPids) runningPids = new Set();
140
+ for (const pid of newPids) runningPids.add(pid);
141
+
142
+ // Re-fetch ports and memory for the new PIDs
143
+ const [newPorts, newMem] = await Promise.all([
144
+ withTimeout(getPortsByPid(newPids), new Map<number, number[]>()),
145
+ withTimeout(getProcessBatchMemory(newPids), new Map<number, number>()),
146
+ ]);
147
+ if (!portMap) portMap = new Map();
148
+ if (!memoryMap) memoryMap = new Map();
149
+ for (const [pid, ports] of newPorts) portMap.set(pid, ports);
150
+ for (const [pid, mem] of newMem) memoryMap.set(pid, mem);
151
+ }
152
+ }
153
+
154
+ return procs.map((p: any) => {
155
+ const running = runningPids?.has(p.pid) ?? false;
156
+ const ports = running ? (portMap?.get(p.pid) || []) : [];
157
+ const memory = running ? (memoryMap?.get(p.pid) || 0) : 0;
158
+
159
+ return {
160
+ name: p.name,
161
+ command: p.command,
162
+ directory: p.workdir,
163
+ pid: p.pid,
164
+ running,
165
+ port: ports.length > 0 ? ports[0] : null,
166
+ ports,
167
+ memory, // Bytes
168
+ group: getProcessGroup(p.env),
169
+ runtime: calculateRuntime(p.timestamp),
170
+ timestamp: p.timestamp,
171
+ env: p.env || '',
172
+ configPath: p.configPath || '',
173
+ stdoutPath: p.stdout_path || '',
174
+ stderrPath: p.stderr_path || '',
175
+ };
176
+ });
177
+ }) ?? [];
141
178
  }
142
179
 
143
- export async function GET() {
180
+ export async function GET(req: Request) {
181
+ const url = new URL(req.url);
182
+ const bustCache = url.searchParams.has('t');
144
183
  const now = Date.now();
145
184
 
146
- // Return cached data if still fresh
147
- if (cache.data && (now - cache.timestamp) < CACHE_TTL_MS) {
185
+ // Return cached data if still fresh and no bust param
186
+ if (!bustCache && cache.data && (now - cache.timestamp) < CACHE_TTL_MS) {
148
187
  return Response.json(cache.data);
149
188
  }
150
189
 
@@ -161,6 +200,11 @@ export async function GET() {
161
200
  });
162
201
  }
163
202
 
164
- const result = await cache.inflight;
165
- return Response.json(result);
203
+ try {
204
+ const result = await cache.inflight;
205
+ return Response.json(result);
206
+ } catch (err) {
207
+ console.error('[api/processes] Error fetching processes:', err);
208
+ return Response.json(cache.data ?? []);
209
+ }
166
210
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * POST /api/restart/:name — Force-restart a process
3
3
  */
4
- import { handleRun } from 'bgrun';
4
+ import { handleRun } from '../../../../../src/commands/run';
5
+ import { measure } from 'measure-fn';
5
6
 
6
7
  export async function POST(req: Request, { params }: { params: { name: string } }) {
7
8
  const name = decodeURIComponent(params.name);
8
9
 
9
10
  try {
10
- await handleRun({
11
+ await measure(`Restart "${name}"`, () => handleRun({
11
12
  action: 'run',
12
13
  name,
13
14
  force: true,
14
15
  remoteName: '',
15
- });
16
+ }));
16
17
  return Response.json({ success: true });
17
18
  } catch (e: any) {
18
19
  return Response.json({ error: e.message }, { status: 500 });
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * POST /api/start — Create or start a process
3
3
  */
4
- import { handleRun } from 'bgrun';
4
+ import { handleRun } from '../../../../src/commands/run';
5
+ import { measure } from 'measure-fn';
5
6
 
6
7
  export async function POST(req: Request) {
7
8
  const body = await req.json();
8
9
 
9
10
  try {
10
- await handleRun({
11
+ await measure(`Start process "${body.name}"`, () => handleRun({
11
12
  action: 'run',
12
13
  name: body.name,
13
14
  command: body.command,
14
15
  directory: body.directory,
15
16
  force: body.force || false,
16
17
  remoteName: '',
17
- });
18
+ }));
18
19
  return Response.json({ success: true });
19
20
  } catch (e: any) {
20
21
  return Response.json({ error: e.message }, { status: 500 });
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * POST /api/stop/:name — Stop a running process
3
3
  */
4
- import { getProcess, isProcessRunning, terminateProcess } from 'bgrun';
4
+ import { getProcess } from '../../../../../src/db';
5
+ import { isProcessRunning, terminateProcess } from '../../../../../src/platform';
6
+ import { measure } from 'measure-fn';
5
7
 
6
8
  export async function POST(req: Request, { params }: { params: { name: string } }) {
7
9
  const name = decodeURIComponent(params.name);
8
10
  const proc = getProcess(name);
9
11
 
10
- if (!proc || !(await isProcessRunning(proc.pid))) {
11
- return Response.json({ error: 'Process not found or not running' }, { status: 404 });
12
+ if (!proc) {
13
+ return Response.json({ error: 'Process not found' }, { status: 404 });
12
14
  }
13
15
 
14
- await terminateProcess(proc.pid);
16
+ const running = await isProcessRunning(proc.pid);
17
+ if (!running) {
18
+ return Response.json({ error: 'Process not running' }, { status: 404 });
19
+ }
20
+
21
+ await measure(`Stop "${name}" (PID ${proc.pid})`, () => terminateProcess(proc.pid));
15
22
  return Response.json({ success: true });
16
23
  }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * GET /api/version — Return BGR version
3
3
  */
4
- import { getVersion } from 'bgrun';
4
+ import { getVersion } from '../../../../src/utils';
5
+ import { measure } from 'measure-fn';
5
6
 
6
7
  export async function GET() {
7
- return Response.json({ version: await getVersion() });
8
+ const version = await measure('Get version', () => getVersion());
9
+ return Response.json({ version });
8
10
  }