everything-dev 0.1.2 → 0.1.4
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/package.json +11 -7
- package/src/cli.ts +109 -1
- package/src/components/monitor-view.tsx +471 -0
- package/src/contract.ts +94 -0
- package/src/lib/nova.ts +11 -11
- package/src/lib/orchestrator.ts +13 -2
- package/src/lib/process.ts +50 -5
- package/src/lib/resource-monitor/assertions.ts +234 -0
- package/src/lib/resource-monitor/command.ts +283 -0
- package/src/lib/resource-monitor/diff.ts +143 -0
- package/src/lib/resource-monitor/errors.ts +127 -0
- package/src/lib/resource-monitor/index.ts +305 -0
- package/src/lib/resource-monitor/platform/darwin.ts +293 -0
- package/src/lib/resource-monitor/platform/index.ts +35 -0
- package/src/lib/resource-monitor/platform/linux.ts +332 -0
- package/src/lib/resource-monitor/platform/windows.ts +298 -0
- package/src/lib/resource-monitor/snapshot.ts +204 -0
- package/src/lib/resource-monitor/types.ts +74 -0
- package/src/lib/session-recorder/errors.ts +102 -0
- package/src/lib/session-recorder/flows/login.ts +210 -0
- package/src/lib/session-recorder/index.ts +361 -0
- package/src/lib/session-recorder/playwright.ts +257 -0
- package/src/lib/session-recorder/report.ts +353 -0
- package/src/lib/session-recorder/server.ts +267 -0
- package/src/lib/session-recorder/types.ts +115 -0
- package/src/plugin.ts +161 -17
- package/src/types.ts +8 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { diffSnapshots, hasLeaks } from "../resource-monitor";
|
|
4
|
+
import { ExportFailed } from "./errors";
|
|
5
|
+
import type {
|
|
6
|
+
SessionConfig,
|
|
7
|
+
SessionEvent,
|
|
8
|
+
SessionReport,
|
|
9
|
+
SessionSummary,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export const generateSummary = (
|
|
13
|
+
events: SessionEvent[],
|
|
14
|
+
config: SessionConfig
|
|
15
|
+
): SessionSummary => {
|
|
16
|
+
if (events.length === 0) {
|
|
17
|
+
return {
|
|
18
|
+
totalMemoryDeltaMb: 0,
|
|
19
|
+
peakMemoryMb: 0,
|
|
20
|
+
averageMemoryMb: 0,
|
|
21
|
+
processesSpawned: 0,
|
|
22
|
+
processesKilled: 0,
|
|
23
|
+
orphanedProcesses: 0,
|
|
24
|
+
portsUsed: config.ports,
|
|
25
|
+
portsLeaked: 0,
|
|
26
|
+
hasLeaks: false,
|
|
27
|
+
eventCount: 0,
|
|
28
|
+
duration: 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const baselineEvent = events.find((e) => e.type === "baseline");
|
|
33
|
+
const lastEvent = events[events.length - 1];
|
|
34
|
+
|
|
35
|
+
const memoryValues = events
|
|
36
|
+
.map((e) => e.snapshot.memory.processRss / 1024 / 1024)
|
|
37
|
+
.filter((v) => v > 0);
|
|
38
|
+
|
|
39
|
+
const peakMemoryMb = Math.max(...memoryValues, 0);
|
|
40
|
+
const averageMemoryMb = memoryValues.length > 0
|
|
41
|
+
? memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length
|
|
42
|
+
: 0;
|
|
43
|
+
|
|
44
|
+
const baselineMemory = baselineEvent?.snapshot.memory.processRss ?? 0;
|
|
45
|
+
const finalMemory = lastEvent.snapshot.memory.processRss;
|
|
46
|
+
const totalMemoryDeltaMb = (finalMemory - baselineMemory) / 1024 / 1024;
|
|
47
|
+
|
|
48
|
+
const allProcessPids = new Set<number>();
|
|
49
|
+
const finalProcessPids = new Set<number>();
|
|
50
|
+
|
|
51
|
+
for (const event of events) {
|
|
52
|
+
for (const proc of event.snapshot.processes) {
|
|
53
|
+
allProcessPids.add(proc.pid);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const proc of lastEvent.snapshot.processes) {
|
|
58
|
+
finalProcessPids.add(proc.pid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const processesSpawned = allProcessPids.size;
|
|
62
|
+
const processesKilled = allProcessPids.size - finalProcessPids.size;
|
|
63
|
+
|
|
64
|
+
let orphanedProcesses = 0;
|
|
65
|
+
let portsLeaked = 0;
|
|
66
|
+
let leaksDetected = false;
|
|
67
|
+
|
|
68
|
+
if (baselineEvent) {
|
|
69
|
+
const diff = diffSnapshots(baselineEvent.snapshot, lastEvent.snapshot);
|
|
70
|
+
orphanedProcesses = diff.orphanedProcesses.length;
|
|
71
|
+
portsLeaked = diff.stillBoundPorts.length;
|
|
72
|
+
leaksDetected = hasLeaks(diff);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const browserMetricsEvents = events.filter((e) => e.browserMetrics);
|
|
76
|
+
let browserMetricsSummary: SessionSummary["browserMetricsSummary"];
|
|
77
|
+
|
|
78
|
+
if (browserMetricsEvents.length > 0) {
|
|
79
|
+
const jsHeapValues = browserMetricsEvents.map(
|
|
80
|
+
(e) => e.browserMetrics!.jsHeapUsedSize / 1024 / 1024
|
|
81
|
+
);
|
|
82
|
+
const layoutCounts = browserMetricsEvents.map(
|
|
83
|
+
(e) => e.browserMetrics!.layoutCount
|
|
84
|
+
);
|
|
85
|
+
const scriptDurations = browserMetricsEvents.map(
|
|
86
|
+
(e) => e.browserMetrics!.scriptDuration
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
browserMetricsSummary = {
|
|
90
|
+
peakJsHeapMb: Math.max(...jsHeapValues),
|
|
91
|
+
averageJsHeapMb: jsHeapValues.reduce((a, b) => a + b, 0) / jsHeapValues.length,
|
|
92
|
+
totalLayoutCount: Math.max(...layoutCounts),
|
|
93
|
+
totalScriptDuration: scriptDurations.reduce((a, b) => a + b, 0),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const duration = lastEvent.timestamp - (baselineEvent?.timestamp ?? events[0].timestamp);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
totalMemoryDeltaMb,
|
|
101
|
+
peakMemoryMb,
|
|
102
|
+
averageMemoryMb,
|
|
103
|
+
processesSpawned,
|
|
104
|
+
processesKilled,
|
|
105
|
+
orphanedProcesses,
|
|
106
|
+
portsUsed: config.ports,
|
|
107
|
+
portsLeaked,
|
|
108
|
+
hasLeaks: leaksDetected,
|
|
109
|
+
eventCount: events.length,
|
|
110
|
+
duration,
|
|
111
|
+
browserMetricsSummary,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const generateReport = (
|
|
116
|
+
sessionId: string,
|
|
117
|
+
config: SessionConfig,
|
|
118
|
+
events: SessionEvent[],
|
|
119
|
+
startTime: number,
|
|
120
|
+
endTime: number
|
|
121
|
+
): SessionReport => {
|
|
122
|
+
const summary = generateSummary(events, config);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
sessionId,
|
|
126
|
+
config,
|
|
127
|
+
startTime,
|
|
128
|
+
endTime,
|
|
129
|
+
events,
|
|
130
|
+
summary,
|
|
131
|
+
platform: process.platform,
|
|
132
|
+
nodeVersion: process.version,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const exportJSON = (
|
|
137
|
+
report: SessionReport,
|
|
138
|
+
filepath: string
|
|
139
|
+
): Effect.Effect<void, ExportFailed> =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
yield* Effect.logInfo(`Exporting session report to ${filepath}`);
|
|
142
|
+
|
|
143
|
+
yield* Effect.tryPromise({
|
|
144
|
+
try: () => writeFile(filepath, JSON.stringify(report, null, 2)),
|
|
145
|
+
catch: (e) => new ExportFailed({
|
|
146
|
+
path: filepath,
|
|
147
|
+
reason: String(e),
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
yield* Effect.logInfo(`Report exported: ${report.events.length} events, ${report.summary.duration}ms duration`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export const formatReportSummary = (report: SessionReport): string => {
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
const { summary } = report;
|
|
157
|
+
|
|
158
|
+
lines.push("═".repeat(60));
|
|
159
|
+
lines.push(" SESSION REPORT SUMMARY");
|
|
160
|
+
lines.push("═".repeat(60));
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push(` Session ID: ${report.sessionId}`);
|
|
163
|
+
lines.push(` Duration: ${(summary.duration / 1000).toFixed(1)}s`);
|
|
164
|
+
lines.push(` Events: ${summary.eventCount}`);
|
|
165
|
+
lines.push(` Platform: ${report.platform}`);
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push("─".repeat(60));
|
|
168
|
+
lines.push(" MEMORY");
|
|
169
|
+
lines.push("─".repeat(60));
|
|
170
|
+
lines.push(` Peak: ${summary.peakMemoryMb.toFixed(1)} MB`);
|
|
171
|
+
lines.push(` Average: ${summary.averageMemoryMb.toFixed(1)} MB`);
|
|
172
|
+
lines.push(` Delta: ${summary.totalMemoryDeltaMb >= 0 ? "+" : ""}${summary.totalMemoryDeltaMb.toFixed(1)} MB`);
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push("─".repeat(60));
|
|
175
|
+
lines.push(" PROCESSES");
|
|
176
|
+
lines.push("─".repeat(60));
|
|
177
|
+
lines.push(` Spawned: ${summary.processesSpawned}`);
|
|
178
|
+
lines.push(` Killed: ${summary.processesKilled}`);
|
|
179
|
+
lines.push(` Orphaned: ${summary.orphanedProcesses}`);
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("─".repeat(60));
|
|
182
|
+
lines.push(" PORTS");
|
|
183
|
+
lines.push("─".repeat(60));
|
|
184
|
+
lines.push(` Monitored: ${summary.portsUsed.join(", ")}`);
|
|
185
|
+
lines.push(` Leaked: ${summary.portsLeaked}`);
|
|
186
|
+
lines.push("");
|
|
187
|
+
|
|
188
|
+
if (summary.browserMetricsSummary) {
|
|
189
|
+
lines.push("─".repeat(60));
|
|
190
|
+
lines.push(" BROWSER METRICS");
|
|
191
|
+
lines.push("─".repeat(60));
|
|
192
|
+
lines.push(` Peak JS Heap: ${summary.browserMetricsSummary.peakJsHeapMb.toFixed(1)} MB`);
|
|
193
|
+
lines.push(` Avg JS Heap: ${summary.browserMetricsSummary.averageJsHeapMb.toFixed(1)} MB`);
|
|
194
|
+
lines.push(` Layout Count: ${summary.browserMetricsSummary.totalLayoutCount}`);
|
|
195
|
+
lines.push(` Script Time: ${summary.browserMetricsSummary.totalScriptDuration.toFixed(2)}s`);
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
lines.push("═".repeat(60));
|
|
200
|
+
if (summary.hasLeaks) {
|
|
201
|
+
lines.push(" ❌ RESOURCE LEAKS DETECTED");
|
|
202
|
+
if (summary.orphanedProcesses > 0) {
|
|
203
|
+
lines.push(` - ${summary.orphanedProcesses} orphaned process(es)`);
|
|
204
|
+
}
|
|
205
|
+
if (summary.portsLeaked > 0) {
|
|
206
|
+
lines.push(` - ${summary.portsLeaked} port(s) still bound`);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
lines.push(" ✅ NO RESOURCE LEAKS");
|
|
210
|
+
}
|
|
211
|
+
lines.push("═".repeat(60));
|
|
212
|
+
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const formatEventTimeline = (events: SessionEvent[]): string => {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
|
|
219
|
+
lines.push("EVENT TIMELINE");
|
|
220
|
+
lines.push("─".repeat(80));
|
|
221
|
+
|
|
222
|
+
const baseTime = events[0]?.timestamp ?? 0;
|
|
223
|
+
|
|
224
|
+
for (const event of events) {
|
|
225
|
+
const elapsed = ((event.timestamp - baseTime) / 1000).toFixed(2).padStart(8);
|
|
226
|
+
const type = event.type.padEnd(12);
|
|
227
|
+
const memory = (event.snapshot.memory.processRss / 1024 / 1024).toFixed(1).padStart(6);
|
|
228
|
+
|
|
229
|
+
lines.push(` ${elapsed}s │ ${type} │ ${memory}MB │ ${event.label}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
lines.push("─".repeat(80));
|
|
233
|
+
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const generateHTMLReport = (report: SessionReport): string => {
|
|
238
|
+
const { summary, events } = report;
|
|
239
|
+
const baseTime = events[0]?.timestamp ?? 0;
|
|
240
|
+
|
|
241
|
+
const eventRows = events.map((e) => {
|
|
242
|
+
const elapsed = ((e.timestamp - baseTime) / 1000).toFixed(2);
|
|
243
|
+
const memory = (e.snapshot.memory.processRss / 1024 / 1024).toFixed(1);
|
|
244
|
+
return `<tr>
|
|
245
|
+
<td>${elapsed}s</td>
|
|
246
|
+
<td><span class="event-type event-${e.type}">${e.type}</span></td>
|
|
247
|
+
<td>${memory} MB</td>
|
|
248
|
+
<td>${e.label}</td>
|
|
249
|
+
</tr>`;
|
|
250
|
+
}).join("\n");
|
|
251
|
+
|
|
252
|
+
return `<!DOCTYPE html>
|
|
253
|
+
<html lang="en">
|
|
254
|
+
<head>
|
|
255
|
+
<meta charset="UTF-8">
|
|
256
|
+
<title>Session Report - ${report.sessionId}</title>
|
|
257
|
+
<style>
|
|
258
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
259
|
+
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
260
|
+
h1 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }
|
|
261
|
+
h2 { color: #555; margin-top: 30px; }
|
|
262
|
+
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0; }
|
|
263
|
+
.summary-card { background: #f8f9fa; padding: 20px; border-radius: 8px; text-align: center; }
|
|
264
|
+
.summary-card .value { font-size: 2em; font-weight: bold; color: #007bff; }
|
|
265
|
+
.summary-card .label { color: #666; font-size: 0.9em; }
|
|
266
|
+
.status { padding: 15px; border-radius: 8px; margin: 20px 0; }
|
|
267
|
+
.status.success { background: #d4edda; color: #155724; }
|
|
268
|
+
.status.error { background: #f8d7da; color: #721c24; }
|
|
269
|
+
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
|
270
|
+
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
271
|
+
th { background: #f8f9fa; }
|
|
272
|
+
.event-type { padding: 4px 8px; border-radius: 4px; font-size: 0.85em; }
|
|
273
|
+
.event-baseline { background: #007bff; color: white; }
|
|
274
|
+
.event-interval { background: #6c757d; color: white; }
|
|
275
|
+
.event-pageload { background: #28a745; color: white; }
|
|
276
|
+
.event-click { background: #ffc107; color: #333; }
|
|
277
|
+
.event-popup_open, .event-popup_close { background: #17a2b8; color: white; }
|
|
278
|
+
.event-auth_start, .event-auth_complete { background: #6f42c1; color: white; }
|
|
279
|
+
.event-custom { background: #fd7e14; color: white; }
|
|
280
|
+
.event-error { background: #dc3545; color: white; }
|
|
281
|
+
</style>
|
|
282
|
+
</head>
|
|
283
|
+
<body>
|
|
284
|
+
<div class="container">
|
|
285
|
+
<h1>Session Report</h1>
|
|
286
|
+
<p><strong>Session ID:</strong> ${report.sessionId}</p>
|
|
287
|
+
<p><strong>Duration:</strong> ${(summary.duration / 1000).toFixed(1)} seconds</p>
|
|
288
|
+
<p><strong>Platform:</strong> ${report.platform} (Node ${report.nodeVersion})</p>
|
|
289
|
+
|
|
290
|
+
<div class="summary-grid">
|
|
291
|
+
<div class="summary-card">
|
|
292
|
+
<div class="value">${summary.eventCount}</div>
|
|
293
|
+
<div class="label">Events</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="summary-card">
|
|
296
|
+
<div class="value">${summary.peakMemoryMb.toFixed(1)} MB</div>
|
|
297
|
+
<div class="label">Peak Memory</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="summary-card">
|
|
300
|
+
<div class="value">${summary.processesSpawned}</div>
|
|
301
|
+
<div class="label">Processes</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="summary-card">
|
|
304
|
+
<div class="value">${summary.portsUsed.length}</div>
|
|
305
|
+
<div class="label">Ports Monitored</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div class="status ${summary.hasLeaks ? 'error' : 'success'}">
|
|
310
|
+
${summary.hasLeaks
|
|
311
|
+
? `❌ Resource leaks detected: ${summary.orphanedProcesses} orphaned processes, ${summary.portsLeaked} ports leaked`
|
|
312
|
+
: '✅ No resource leaks detected'
|
|
313
|
+
}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<h2>Event Timeline</h2>
|
|
317
|
+
<table>
|
|
318
|
+
<thead>
|
|
319
|
+
<tr>
|
|
320
|
+
<th>Time</th>
|
|
321
|
+
<th>Event</th>
|
|
322
|
+
<th>Memory</th>
|
|
323
|
+
<th>Label</th>
|
|
324
|
+
</tr>
|
|
325
|
+
</thead>
|
|
326
|
+
<tbody>
|
|
327
|
+
${eventRows}
|
|
328
|
+
</tbody>
|
|
329
|
+
</table>
|
|
330
|
+
</div>
|
|
331
|
+
</body>
|
|
332
|
+
</html>`;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export const exportHTMLReport = (
|
|
336
|
+
report: SessionReport,
|
|
337
|
+
filepath: string
|
|
338
|
+
): Effect.Effect<void, ExportFailed> =>
|
|
339
|
+
Effect.gen(function* () {
|
|
340
|
+
yield* Effect.logInfo(`Exporting HTML report to ${filepath}`);
|
|
341
|
+
|
|
342
|
+
const html = generateHTMLReport(report);
|
|
343
|
+
|
|
344
|
+
yield* Effect.tryPromise({
|
|
345
|
+
try: () => writeFile(filepath, html),
|
|
346
|
+
catch: (e) => new ExportFailed({
|
|
347
|
+
path: filepath,
|
|
348
|
+
reason: String(e),
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
yield* Effect.logInfo("HTML report exported");
|
|
353
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import {
|
|
6
|
+
createSnapshotWithPlatform,
|
|
7
|
+
runSilent,
|
|
8
|
+
} from "../resource-monitor";
|
|
9
|
+
import { ServerNotReady, ServerStartFailed } from "./errors";
|
|
10
|
+
import type { ServerHandle, ServerOrchestrator } from "./types";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const CLI_DIR = resolve(__dirname, "../../..");
|
|
14
|
+
|
|
15
|
+
const sleep = (ms: number): Promise<void> =>
|
|
16
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
interface SpawnOptions {
|
|
19
|
+
port?: number;
|
|
20
|
+
account?: string;
|
|
21
|
+
domain?: string;
|
|
22
|
+
interactive?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const createServerHandle = (
|
|
26
|
+
proc: ReturnType<typeof spawn>,
|
|
27
|
+
name: string,
|
|
28
|
+
port: number
|
|
29
|
+
): ServerHandle => {
|
|
30
|
+
proc.stdout?.on("data", () => {});
|
|
31
|
+
proc.stderr?.on("data", () => {});
|
|
32
|
+
|
|
33
|
+
let exitHandled = false;
|
|
34
|
+
let exitCode: number | null = null;
|
|
35
|
+
const exitPromise = new Promise<number | null>((resolve) => {
|
|
36
|
+
(proc as unknown as NodeJS.EventEmitter).on("exit", (code: number | null) => {
|
|
37
|
+
exitHandled = true;
|
|
38
|
+
exitCode = code;
|
|
39
|
+
resolve(code);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
pid: proc.pid!,
|
|
45
|
+
port,
|
|
46
|
+
name,
|
|
47
|
+
kill: async () => {
|
|
48
|
+
proc.kill("SIGTERM");
|
|
49
|
+
const killPromise = new Promise<void>((res) => {
|
|
50
|
+
const timeout = setTimeout(() => {
|
|
51
|
+
proc.kill("SIGKILL");
|
|
52
|
+
res();
|
|
53
|
+
}, 5000);
|
|
54
|
+
if (exitHandled) {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
res();
|
|
57
|
+
} else {
|
|
58
|
+
exitPromise.then(() => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
res();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
await killPromise;
|
|
65
|
+
},
|
|
66
|
+
waitForExit: (timeoutMs = 10000): Promise<number | null> =>
|
|
67
|
+
new Promise((res) => {
|
|
68
|
+
const timeout = setTimeout(() => res(null), timeoutMs);
|
|
69
|
+
if (exitHandled) {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
res(exitCode);
|
|
72
|
+
} else {
|
|
73
|
+
exitPromise.then((code) => {
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
res(code);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const spawnBosStart = (options: SpawnOptions = {}): ServerHandle => {
|
|
83
|
+
const args = [
|
|
84
|
+
"run",
|
|
85
|
+
"src/cli.ts",
|
|
86
|
+
"start",
|
|
87
|
+
"--account", options.account || "every.near",
|
|
88
|
+
"--domain", options.domain || "everything.dev",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
if (!options.interactive) {
|
|
92
|
+
args.push("--no-interactive");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (options.port) {
|
|
96
|
+
args.push("--port", String(options.port));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const proc = spawn("bun", args, {
|
|
100
|
+
cwd: CLI_DIR,
|
|
101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
102
|
+
detached: false,
|
|
103
|
+
env: { ...process.env, NODE_ENV: "production" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return createServerHandle(proc, "bos-start", options.port || 3000);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const spawnBosDev = (options: SpawnOptions = {}): ServerHandle => {
|
|
110
|
+
const args = ["run", "src/cli.ts", "dev"];
|
|
111
|
+
|
|
112
|
+
if (!options.interactive) {
|
|
113
|
+
args.push("--no-interactive");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.port) {
|
|
117
|
+
args.push("--port", String(options.port));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const proc = spawn("bun", args, {
|
|
121
|
+
cwd: CLI_DIR,
|
|
122
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
123
|
+
detached: false,
|
|
124
|
+
env: { ...process.env, NODE_ENV: "development" },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return createServerHandle(proc, "bos-dev", options.port || 3000);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const waitForPortBound = async (
|
|
131
|
+
port: number,
|
|
132
|
+
timeoutMs = 60000
|
|
133
|
+
): Promise<boolean> => {
|
|
134
|
+
const start = Date.now();
|
|
135
|
+
|
|
136
|
+
while (Date.now() - start < timeoutMs) {
|
|
137
|
+
try {
|
|
138
|
+
const snapshot = await runSilent(
|
|
139
|
+
createSnapshotWithPlatform({ ports: [port] })
|
|
140
|
+
);
|
|
141
|
+
if (snapshot.ports[port]?.state === "LISTEN") {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore errors during polling
|
|
146
|
+
}
|
|
147
|
+
await sleep(500);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const waitForPortFree = async (
|
|
154
|
+
port: number,
|
|
155
|
+
timeoutMs = 15000
|
|
156
|
+
): Promise<boolean> => {
|
|
157
|
+
const start = Date.now();
|
|
158
|
+
|
|
159
|
+
while (Date.now() - start < timeoutMs) {
|
|
160
|
+
try {
|
|
161
|
+
const snapshot = await runSilent(
|
|
162
|
+
createSnapshotWithPlatform({ ports: [port] })
|
|
163
|
+
);
|
|
164
|
+
if (snapshot.ports[port]?.state === "FREE") {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore errors during polling
|
|
169
|
+
}
|
|
170
|
+
await sleep(200);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return false;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const startServers = (
|
|
177
|
+
mode: "start" | "dev" = "start",
|
|
178
|
+
options: SpawnOptions = {}
|
|
179
|
+
): Effect.Effect<ServerOrchestrator, ServerStartFailed | ServerNotReady> =>
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
const port = options.port || 3000;
|
|
182
|
+
|
|
183
|
+
yield* Effect.logInfo(`Starting BOS in ${mode} mode on port ${port}`);
|
|
184
|
+
|
|
185
|
+
const handle = mode === "dev"
|
|
186
|
+
? spawnBosDev(options)
|
|
187
|
+
: spawnBosStart(options);
|
|
188
|
+
|
|
189
|
+
const ready = yield* Effect.tryPromise({
|
|
190
|
+
try: () => waitForPortBound(port, 90000),
|
|
191
|
+
catch: (e) => new ServerStartFailed({
|
|
192
|
+
server: handle.name,
|
|
193
|
+
port,
|
|
194
|
+
reason: String(e),
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!ready) {
|
|
199
|
+
yield* Effect.promise(() => handle.kill());
|
|
200
|
+
return yield* Effect.fail(
|
|
201
|
+
new ServerNotReady({
|
|
202
|
+
servers: [handle.name],
|
|
203
|
+
timeoutMs: 90000,
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
yield* Effect.logInfo(`Server ready on port ${port}`);
|
|
209
|
+
|
|
210
|
+
const orchestrator: ServerOrchestrator = {
|
|
211
|
+
handles: [handle],
|
|
212
|
+
ports: [port],
|
|
213
|
+
shutdown: async () => {
|
|
214
|
+
console.log("Shutting down servers");
|
|
215
|
+
await handle.kill();
|
|
216
|
+
await waitForPortFree(port, 15000);
|
|
217
|
+
console.log("Servers stopped");
|
|
218
|
+
},
|
|
219
|
+
waitForReady: async () => {
|
|
220
|
+
return waitForPortBound(port, 30000);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return orchestrator;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export const shutdownServers = (
|
|
228
|
+
orchestrator: ServerOrchestrator
|
|
229
|
+
): Effect.Effect<void> =>
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
yield* Effect.logInfo(`Shutting down ${orchestrator.handles.length} server(s)`);
|
|
232
|
+
|
|
233
|
+
for (const handle of orchestrator.handles) {
|
|
234
|
+
yield* Effect.logDebug(`Killing ${handle.name} (PID ${handle.pid})`);
|
|
235
|
+
yield* Effect.promise(() => handle.kill());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const port of orchestrator.ports) {
|
|
239
|
+
yield* Effect.logDebug(`Waiting for port ${port} to be free`);
|
|
240
|
+
const freed = yield* Effect.promise(() => waitForPortFree(port, 15000));
|
|
241
|
+
if (!freed) {
|
|
242
|
+
yield* Effect.logWarning(`Port ${port} still bound after shutdown`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
yield* Effect.logInfo("All servers stopped");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const checkPortsAvailable = (
|
|
250
|
+
ports: number[]
|
|
251
|
+
): Effect.Effect<boolean> =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const snapshot = yield* Effect.promise(() =>
|
|
254
|
+
runSilent(createSnapshotWithPlatform({ ports }))
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
for (const port of ports) {
|
|
258
|
+
if (snapshot.ports[port]?.state !== "FREE") {
|
|
259
|
+
yield* Effect.logWarning(`Port ${port} is already in use`);
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return true;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
export { waitForPortBound, waitForPortFree };
|