agent-lyceum 0.28.0 → 0.29.0
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 +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/cli.js +51 -1
- package/dist/project-lock.d.ts +2 -0
- package/dist/project-lock.js +27 -2
- package/dist/quota.d.ts +25 -0
- package/dist/quota.js +141 -0
- package/dist/status-watch.d.ts +5 -1
- package/dist/status-watch.js +5 -3
- package/package.json +5 -3
- package/web/dist/public/assets/index-B1Ld6Pl5.css +2 -0
- package/web/dist/public/assets/index-D2JeeQpl.js +9 -0
- package/web/dist/public/index.html +13 -0
- package/web/dist/server/http.js +94 -0
- package/web/dist/server/hub.js +97 -0
- package/web/dist/server/open.js +9 -0
- package/web/dist/start.js +58 -0
- package/web/dist/types.js +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>agent-lyceum</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D2JeeQpl.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B1Ld6Pl5.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MIME = {
|
|
5
|
+
".html": "text/html; charset=utf-8",
|
|
6
|
+
".js": "text/javascript; charset=utf-8",
|
|
7
|
+
".css": "text/css; charset=utf-8",
|
|
8
|
+
".svg": "image/svg+xml",
|
|
9
|
+
".json": "application/json; charset=utf-8",
|
|
10
|
+
".woff2": "font/woff2",
|
|
11
|
+
};
|
|
12
|
+
function inside(root, file) {
|
|
13
|
+
const rel = path.relative(root, file);
|
|
14
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
15
|
+
}
|
|
16
|
+
function sendFile(res, file) {
|
|
17
|
+
const ext = path.extname(file);
|
|
18
|
+
res.writeHead(200, {
|
|
19
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
20
|
+
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600",
|
|
21
|
+
});
|
|
22
|
+
fs.createReadStream(file).pipe(res);
|
|
23
|
+
}
|
|
24
|
+
export function serveStatic(staticDir, req, res) {
|
|
25
|
+
const root = path.resolve(staticDir);
|
|
26
|
+
let pathname = "/";
|
|
27
|
+
try {
|
|
28
|
+
pathname = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
res.writeHead(400);
|
|
32
|
+
res.end();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const requested = path.resolve(root, `.${pathname}`);
|
|
36
|
+
if (!inside(root, requested)) {
|
|
37
|
+
res.writeHead(403);
|
|
38
|
+
res.end();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (fs.existsSync(requested) && fs.statSync(requested).isFile()) {
|
|
42
|
+
sendFile(res, requested);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const ext = path.extname(pathname);
|
|
46
|
+
const index = path.join(root, "index.html");
|
|
47
|
+
if ((ext && ext !== ".html") || !fs.existsSync(index)) {
|
|
48
|
+
res.writeHead(404);
|
|
49
|
+
res.end();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
sendFile(res, index);
|
|
53
|
+
}
|
|
54
|
+
export function writeEvent(res, event) {
|
|
55
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
56
|
+
}
|
|
57
|
+
export function attachEvents(hub, req, res) {
|
|
58
|
+
res.writeHead(200, {
|
|
59
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
60
|
+
"Cache-Control": "no-cache, no-transform",
|
|
61
|
+
Connection: "keep-alive",
|
|
62
|
+
});
|
|
63
|
+
const unsubscribe = hub.subscribe((event) => writeEvent(res, event));
|
|
64
|
+
const ping = setInterval(() => {
|
|
65
|
+
res.write(": ping\n\n");
|
|
66
|
+
}, 15_000);
|
|
67
|
+
const done = () => {
|
|
68
|
+
clearInterval(ping);
|
|
69
|
+
unsubscribe();
|
|
70
|
+
};
|
|
71
|
+
req.on("close", done);
|
|
72
|
+
res.on("error", done);
|
|
73
|
+
}
|
|
74
|
+
export function listen(port, host, onRequest) {
|
|
75
|
+
const server = http.createServer(onRequest);
|
|
76
|
+
const sockets = new Set();
|
|
77
|
+
server.on("connection", (socket) => {
|
|
78
|
+
sockets.add(socket);
|
|
79
|
+
socket.on("close", () => sockets.delete(socket));
|
|
80
|
+
});
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const fail = (err) => {
|
|
83
|
+
if (err.code === "EADDRINUSE")
|
|
84
|
+
reject(new Error(`Port ${port} is already in use.`));
|
|
85
|
+
else
|
|
86
|
+
reject(err);
|
|
87
|
+
};
|
|
88
|
+
server.once("error", fail);
|
|
89
|
+
server.listen(port, host, () => {
|
|
90
|
+
server.off("error", fail);
|
|
91
|
+
resolve({ server, sockets });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export function createWebHub(factory) {
|
|
2
|
+
const watchers = new Map();
|
|
3
|
+
const latest = new Map();
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
let quota;
|
|
6
|
+
let quotaToken = 0;
|
|
7
|
+
const emit = (event) => {
|
|
8
|
+
for (const listener of listeners) {
|
|
9
|
+
try {
|
|
10
|
+
listener(event);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
/* one subscriber cannot stop the others */
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const sync = () => {
|
|
18
|
+
let names;
|
|
19
|
+
try {
|
|
20
|
+
names = factory.list();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const want = new Set(names);
|
|
26
|
+
for (const [name, watch] of watchers) {
|
|
27
|
+
if (want.has(name))
|
|
28
|
+
continue;
|
|
29
|
+
watch.close();
|
|
30
|
+
watchers.delete(name);
|
|
31
|
+
latest.delete(name);
|
|
32
|
+
emit({ type: "removed", name });
|
|
33
|
+
}
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
if (watchers.has(name))
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const watch = factory.watch(name, (frame) => {
|
|
39
|
+
latest.set(name, frame);
|
|
40
|
+
emit({ type: "frame", name, frame });
|
|
41
|
+
});
|
|
42
|
+
watchers.set(name, watch);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
const frame = { schema_version: 1, error: e.message };
|
|
46
|
+
latest.set(name, frame);
|
|
47
|
+
emit({ type: "frame", name, frame });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const watch of watchers.values()) {
|
|
51
|
+
try {
|
|
52
|
+
watch.beat();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* one project's sweep cannot stop the others */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const refreshQuota = () => {
|
|
60
|
+
if (!factory.quota)
|
|
61
|
+
return Promise.resolve(quota);
|
|
62
|
+
const token = ++quotaToken;
|
|
63
|
+
return factory
|
|
64
|
+
.quota()
|
|
65
|
+
.then((reports) => {
|
|
66
|
+
if (token !== quotaToken)
|
|
67
|
+
return quota;
|
|
68
|
+
quota = reports;
|
|
69
|
+
emit({ type: "quota", reports });
|
|
70
|
+
return reports;
|
|
71
|
+
})
|
|
72
|
+
.catch(() => quota);
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
start: sync,
|
|
76
|
+
beat: sync,
|
|
77
|
+
refreshQuota,
|
|
78
|
+
close() {
|
|
79
|
+
quotaToken += 1;
|
|
80
|
+
for (const watch of watchers.values())
|
|
81
|
+
watch.close();
|
|
82
|
+
watchers.clear();
|
|
83
|
+
listeners.clear();
|
|
84
|
+
},
|
|
85
|
+
subscribe(listener) {
|
|
86
|
+
listeners.add(listener);
|
|
87
|
+
const projects = [...latest].map(([name, frame]) => ({ name, frame })).sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
try {
|
|
89
|
+
listener({ type: "snapshot", projects, ...(quota ? { quota } : {}) });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
listeners.delete(listener);
|
|
93
|
+
}
|
|
94
|
+
return () => listeners.delete(listener);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function openBrowser(url) {
|
|
3
|
+
if (process.platform === "darwin")
|
|
4
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
5
|
+
else if (process.platform === "win32")
|
|
6
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
7
|
+
else
|
|
8
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
9
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createWebHub } from "./server/hub.js";
|
|
2
|
+
import { attachEvents, listen, serveStatic } from "./server/http.js";
|
|
3
|
+
import { openBrowser } from "./server/open.js";
|
|
4
|
+
export async function startWebServer(opts) {
|
|
5
|
+
const hub = createWebHub(opts.factory);
|
|
6
|
+
hub.start();
|
|
7
|
+
hub.refreshQuota();
|
|
8
|
+
const heartbeat = setInterval(() => hub.beat(), opts.heartbeatMs ?? 5_000);
|
|
9
|
+
const quotaBeat = setInterval(() => hub.refreshQuota(), 60_000);
|
|
10
|
+
const { server, sockets } = await listen(opts.port, opts.host, (req, res) => {
|
|
11
|
+
const pathname = (req.url ?? "/").split("?")[0] ?? "/";
|
|
12
|
+
if (pathname === "/events") {
|
|
13
|
+
attachEvents(hub, req, res);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (pathname === "/quota" && req.method === "POST") {
|
|
17
|
+
void hub.refreshQuota().then((reports) => {
|
|
18
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
19
|
+
res.end(JSON.stringify(reports ?? []));
|
|
20
|
+
}, () => {
|
|
21
|
+
if (res.headersSent)
|
|
22
|
+
return;
|
|
23
|
+
res.writeHead(500);
|
|
24
|
+
res.end();
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
29
|
+
res.writeHead(405);
|
|
30
|
+
res.end();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
serveStatic(opts.staticDir, req, res);
|
|
34
|
+
});
|
|
35
|
+
const address = server.address();
|
|
36
|
+
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
37
|
+
const host = opts.host === "0.0.0.0" || opts.host === "::" ? "127.0.0.1" : opts.host;
|
|
38
|
+
const shown = host.includes(":") ? `[${host}]` : host;
|
|
39
|
+
const url = `http://${shown}:${port}/`;
|
|
40
|
+
if (opts.open)
|
|
41
|
+
openBrowser(url);
|
|
42
|
+
return {
|
|
43
|
+
url,
|
|
44
|
+
close: () => new Promise((resolve, reject) => {
|
|
45
|
+
clearInterval(heartbeat);
|
|
46
|
+
clearInterval(quotaBeat);
|
|
47
|
+
hub.close();
|
|
48
|
+
for (const socket of sockets)
|
|
49
|
+
socket.destroy();
|
|
50
|
+
server.close((err) => {
|
|
51
|
+
if (err && err.code !== "ERR_SERVER_NOT_RUNNING")
|
|
52
|
+
reject(err);
|
|
53
|
+
else
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|