ai-token-usage-lite 0.1.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.
@@ -0,0 +1,100 @@
1
+ function aggregateEvents(events) {
2
+ const cleanEvents = events.filter(Boolean).map(sanitizeEvent);
3
+ const summary = emptyTotals();
4
+ const sourceMap = new Map();
5
+ const modelMap = new Map();
6
+ const dayMap = new Map();
7
+ const projectMap = new Map();
8
+
9
+ for (const event of cleanEvents) {
10
+ addTotals(summary, event);
11
+ addToMap(sourceMap, event.source, { source: event.source }, event);
12
+ addToMap(modelMap, `${event.source}:${event.model}`, {
13
+ source: event.source,
14
+ model: event.model,
15
+ }, event);
16
+ addToMap(dayMap, dayKey(event.timestamp), { day: dayKey(event.timestamp) }, event);
17
+ if (event.projectRef) {
18
+ addToMap(projectMap, `${event.source}:${event.projectRef}`, {
19
+ source: event.source,
20
+ projectRef: event.projectRef,
21
+ }, event);
22
+ }
23
+ }
24
+
25
+ return {
26
+ generatedAt: new Date().toISOString(),
27
+ summary,
28
+ bySource: sortRows(sourceMap),
29
+ byModel: sortRows(modelMap),
30
+ byDay: Array.from(dayMap.values()).sort((a, b) => a.day.localeCompare(b.day)),
31
+ byProject: sortRows(projectMap),
32
+ };
33
+ }
34
+
35
+ function sanitizeEvent(event) {
36
+ return {
37
+ source: String(event.source || "unknown"),
38
+ model: String(event.model || "unknown"),
39
+ timestamp: normalizeTimestamp(event.timestamp),
40
+ projectRef: event.projectRef || null,
41
+ inputTokens: number(event.inputTokens),
42
+ outputTokens: number(event.outputTokens),
43
+ cachedInputTokens: number(event.cachedInputTokens),
44
+ cacheCreationInputTokens: number(event.cacheCreationInputTokens),
45
+ reasoningOutputTokens: number(event.reasoningOutputTokens),
46
+ totalTokens: number(event.totalTokens),
47
+ exact: event.exact !== false,
48
+ };
49
+ }
50
+
51
+ function addToMap(map, key, base, event) {
52
+ if (!map.has(key)) map.set(key, { ...base, ...emptyTotals() });
53
+ addTotals(map.get(key), event);
54
+ }
55
+
56
+ function addTotals(target, event) {
57
+ target.inputTokens += number(event.inputTokens);
58
+ target.outputTokens += number(event.outputTokens);
59
+ target.cachedInputTokens += number(event.cachedInputTokens);
60
+ target.cacheCreationInputTokens += number(event.cacheCreationInputTokens);
61
+ target.reasoningOutputTokens += number(event.reasoningOutputTokens);
62
+ target.totalTokens += number(event.totalTokens);
63
+ target.conversationCount += 1;
64
+ }
65
+
66
+ function emptyTotals() {
67
+ return {
68
+ inputTokens: 0,
69
+ outputTokens: 0,
70
+ cachedInputTokens: 0,
71
+ cacheCreationInputTokens: 0,
72
+ reasoningOutputTokens: 0,
73
+ totalTokens: 0,
74
+ conversationCount: 0,
75
+ };
76
+ }
77
+
78
+ function dayKey(timestamp) {
79
+ return normalizeTimestamp(timestamp).slice(0, 10);
80
+ }
81
+
82
+ function normalizeTimestamp(value) {
83
+ const date = value ? new Date(value) : new Date();
84
+ if (Number.isNaN(date.getTime())) return new Date().toISOString();
85
+ return date.toISOString();
86
+ }
87
+
88
+ function sortRows(map) {
89
+ return Array.from(map.values()).sort((a, b) => b.totalTokens - a.totalTokens);
90
+ }
91
+
92
+ function number(value) {
93
+ return Number.isFinite(Number(value)) && Number(value) > 0 ? Math.round(Number(value)) : 0;
94
+ }
95
+
96
+ module.exports = {
97
+ aggregateEvents,
98
+ sanitizeEvent,
99
+ emptyTotals,
100
+ };
package/src/cli.js ADDED
@@ -0,0 +1,115 @@
1
+ const { syncUsage } = require("./sync");
2
+ const { getStatus } = require("./status");
3
+ const { getDoctorReport } = require("./doctor");
4
+ const { exportUsage } = require("./export");
5
+ const { serve } = require("./server");
6
+
7
+ async function run(argv) {
8
+ const { command, rest } = parseCommand(argv);
9
+ if (!command || command === "serve") {
10
+ const opts = parseArgs(rest);
11
+ await syncUsage(opts);
12
+ await serve(opts);
13
+ return;
14
+ }
15
+ if (command === "sync") {
16
+ const result = await syncUsage(parseArgs(rest));
17
+ process.stdout.write(formatSyncResult(result));
18
+ return;
19
+ }
20
+ if (command === "status") {
21
+ const opts = parseArgs(rest);
22
+ const status = await getStatus(opts);
23
+ writeStructured(status, opts);
24
+ return;
25
+ }
26
+ if (command === "doctor") {
27
+ const opts = parseArgs(rest);
28
+ const report = await getDoctorReport(opts);
29
+ writeStructured(report, opts);
30
+ process.exitCode = report.ok ? 0 : 1;
31
+ return;
32
+ }
33
+ if (command === "export") {
34
+ await exportUsage(parseArgs(rest));
35
+ return;
36
+ }
37
+ if (command === "--help" || command === "-h" || command === "help") {
38
+ printHelp();
39
+ return;
40
+ }
41
+ throw new Error(`Unknown command: ${command}`);
42
+ }
43
+
44
+ function parseCommand(argv) {
45
+ const args = Array.isArray(argv) ? argv : [];
46
+ return { command: args[0], rest: args.slice(1) };
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const opts = {
51
+ home: null,
52
+ dataDir: null,
53
+ port: 7680,
54
+ json: false,
55
+ format: "json",
56
+ };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const arg = argv[i];
59
+ if (arg === "--home") opts.home = argv[++i];
60
+ else if (arg === "--data-dir") opts.dataDir = argv[++i];
61
+ else if (arg === "--port") opts.port = Number(argv[++i]);
62
+ else if (arg === "--json") opts.json = true;
63
+ else if (arg === "--format") opts.format = argv[++i] || "json";
64
+ }
65
+ return opts;
66
+ }
67
+
68
+ function writeStructured(value, opts) {
69
+ if (opts.json) {
70
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
71
+ return;
72
+ }
73
+ process.stdout.write(`${humanize(value)}\n`);
74
+ }
75
+
76
+ function humanize(value) {
77
+ if (!value || typeof value !== "object") return String(value);
78
+ if (value.providers) {
79
+ const lines = ["Provider status:"];
80
+ for (const [name, provider] of Object.entries(value.providers)) {
81
+ const state = provider.installed ? "installed" : "missing";
82
+ const details = provider.detail ? ` - ${provider.detail}` : "";
83
+ lines.push(` ${name}: ${state}${details}`);
84
+ }
85
+ return lines.join("\n");
86
+ }
87
+ return JSON.stringify(value, null, 2);
88
+ }
89
+
90
+ function formatSyncResult(result) {
91
+ const total = result && result.data ? result.data.summary.totalTokens : 0;
92
+ const sources = result && result.data ? result.data.bySource.length : 0;
93
+ return `Synced ${total.toLocaleString()} tokens across ${sources} sources.\nData: ${result.outputPath}\n`;
94
+ }
95
+
96
+ function printHelp() {
97
+ process.stdout.write(
98
+ [
99
+ "ai-token-usage-lite",
100
+ "",
101
+ "Usage:",
102
+ " ai-token-usage-lite serve [--port 7680] [--home <path>]",
103
+ " ai-token-usage-lite sync [--home <path>]",
104
+ " ai-token-usage-lite status [--json] [--home <path>]",
105
+ " ai-token-usage-lite doctor [--json] [--home <path>]",
106
+ " ai-token-usage-lite export --format json|csv [--home <path>]",
107
+ "",
108
+ ].join("\n"),
109
+ );
110
+ }
111
+
112
+ module.exports = {
113
+ run,
114
+ parseArgs,
115
+ };
package/src/doctor.js ADDED
@@ -0,0 +1,30 @@
1
+ const { getStatus } = require("./status");
2
+
3
+ async function getDoctorReport(options = {}) {
4
+ const status = await getStatus(options);
5
+ const checks = [
6
+ {
7
+ id: "runtime.node",
8
+ ok: Number(process.versions.node.split(".")[0]) >= 20,
9
+ detail: `node ${process.version}`,
10
+ },
11
+ {
12
+ id: "provider.claude",
13
+ ok: status.providers.claude.installed,
14
+ detail: status.providers.claude.detail,
15
+ },
16
+ {
17
+ id: "provider.codex",
18
+ ok: status.providers.codex.installed,
19
+ detail: status.providers.codex.detail,
20
+ },
21
+ ];
22
+ return {
23
+ generatedAt: new Date().toISOString(),
24
+ ok: checks.every((check) => check.ok || check.optional),
25
+ checks,
26
+ status,
27
+ };
28
+ }
29
+
30
+ module.exports = { getDoctorReport };
package/src/export.js ADDED
@@ -0,0 +1,32 @@
1
+ const { resolvePaths } = require("./paths");
2
+ const { readJson } = require("./fs-utils");
3
+
4
+ async function exportUsage(options = {}) {
5
+ const paths = resolvePaths(options);
6
+ const data = await readJson(paths.usagePath);
7
+ if (!data) throw new Error(`No usage data found. Run sync first: ${paths.usagePath}`);
8
+ if (options.format === "csv") {
9
+ process.stdout.write(toCsv(data));
10
+ return;
11
+ }
12
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
13
+ }
14
+
15
+ function toCsv(data) {
16
+ const rows = [
17
+ ["group", "source", "model", "day", "projectRef", "totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "conversationCount"],
18
+ ];
19
+ for (const row of data.bySource || []) rows.push(["source", row.source, "", "", "", row.totalTokens, row.inputTokens, row.outputTokens, row.cachedInputTokens, row.conversationCount]);
20
+ for (const row of data.byModel || []) rows.push(["model", row.source, row.model, "", "", row.totalTokens, row.inputTokens, row.outputTokens, row.cachedInputTokens, row.conversationCount]);
21
+ for (const row of data.byDay || []) rows.push(["day", "", "", row.day, "", row.totalTokens, row.inputTokens, row.outputTokens, row.cachedInputTokens, row.conversationCount]);
22
+ for (const row of data.byProject || []) rows.push(["project", row.source, "", "", row.projectRef, row.totalTokens, row.inputTokens, row.outputTokens, row.cachedInputTokens, row.conversationCount]);
23
+ return rows.map((row) => row.map(csvCell).join(",")).join("\n") + "\n";
24
+ }
25
+
26
+ function csvCell(value) {
27
+ const text = String(value ?? "");
28
+ if (!/[",\n]/.test(text)) return text;
29
+ return `"${text.replace(/"/g, '""')}"`;
30
+ }
31
+
32
+ module.exports = { exportUsage, toCsv };
@@ -0,0 +1,65 @@
1
+ const fs = require("node:fs/promises");
2
+ const fssync = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ async function ensureDir(dir) {
6
+ await fs.mkdir(dir, { recursive: true });
7
+ }
8
+
9
+ async function writeJson(filePath, value) {
10
+ await ensureDir(path.dirname(filePath));
11
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
12
+ }
13
+
14
+ async function readJson(filePath) {
15
+ try {
16
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ async function exists(filePath) {
23
+ try {
24
+ await fs.access(filePath);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function walk(dir, predicate = () => true) {
32
+ const out = [];
33
+ async function visit(current) {
34
+ let entries;
35
+ try {
36
+ entries = await fs.readdir(current, { withFileTypes: true });
37
+ } catch {
38
+ return;
39
+ }
40
+ for (const entry of entries) {
41
+ const full = path.join(current, entry.name);
42
+ if (entry.isDirectory()) await visit(full);
43
+ else if (entry.isFile() && predicate(full)) out.push(full);
44
+ }
45
+ }
46
+ await visit(dir);
47
+ return out;
48
+ }
49
+
50
+ function existsSync(filePath) {
51
+ try {
52
+ return fssync.existsSync(filePath);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ ensureDir,
60
+ writeJson,
61
+ readJson,
62
+ exists,
63
+ existsSync,
64
+ walk,
65
+ };
package/src/paths.js ADDED
@@ -0,0 +1,16 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+
4
+ function resolvePaths(options = {}) {
5
+ const home = options.home || process.env.HOME || os.homedir();
6
+ const dataDir = options.dataDir || path.join(home, ".ai-token-usage-lite");
7
+ return {
8
+ home,
9
+ dataDir,
10
+ usagePath: path.join(dataDir, "usage.json"),
11
+ claudeProjectsDir: path.join(home, ".claude", "projects"),
12
+ codexSessionsDir: path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "sessions"),
13
+ };
14
+ }
15
+
16
+ module.exports = { resolvePaths };
@@ -0,0 +1,69 @@
1
+ const fs = require("node:fs/promises");
2
+ const { walk } = require("../fs-utils");
3
+
4
+ async function scanClaude(paths) {
5
+ const files = await walk(paths.claudeProjectsDir, (p) => p.endsWith(".jsonl"));
6
+ const events = [];
7
+ for (const file of files) {
8
+ const text = await fs.readFile(file, "utf8").catch(() => "");
9
+ for (const line of text.split(/\r?\n/)) {
10
+ const event = parseClaudeLine(line, file);
11
+ if (event) events.push(event);
12
+ }
13
+ }
14
+ return {
15
+ provider: "claude",
16
+ files: files.length,
17
+ events,
18
+ };
19
+ }
20
+
21
+ function parseClaudeLine(line, filePath) {
22
+ if (!line || !line.trim()) return null;
23
+ let obj;
24
+ try {
25
+ obj = JSON.parse(line);
26
+ } catch {
27
+ return null;
28
+ }
29
+ if (obj.type !== "assistant" || !obj.message) return null;
30
+ const usage = obj.message.usage;
31
+ if (!usage || typeof usage !== "object") return null;
32
+ const inputTokens = num(usage.input_tokens);
33
+ const outputTokens = num(usage.output_tokens);
34
+ const cachedInputTokens = num(usage.cache_read_input_tokens);
35
+ const cacheCreationInputTokens =
36
+ num(usage.cache_creation_input_tokens) ||
37
+ num(usage.claude_cache_creation_5_m_tokens) ||
38
+ num(usage.claude_cache_creation_1_h_tokens);
39
+ const reasoningOutputTokens = num(usage.reasoning_output_tokens);
40
+ const totalTokens =
41
+ inputTokens + outputTokens + cachedInputTokens + cacheCreationInputTokens;
42
+ if (totalTokens === 0) return null;
43
+ return {
44
+ source: "claude",
45
+ model: obj.message.model || "unknown",
46
+ timestamp: obj.timestamp || obj.created_at || new Date().toISOString(),
47
+ projectRef: obj.cwd || projectFromFile(filePath),
48
+ inputTokens,
49
+ outputTokens,
50
+ cachedInputTokens,
51
+ cacheCreationInputTokens,
52
+ reasoningOutputTokens,
53
+ totalTokens,
54
+ exact: true,
55
+ };
56
+ }
57
+
58
+ function projectFromFile(filePath) {
59
+ return filePath || null;
60
+ }
61
+
62
+ function num(value) {
63
+ return Number.isFinite(Number(value)) && Number(value) > 0 ? Math.round(Number(value)) : 0;
64
+ }
65
+
66
+ module.exports = {
67
+ scanClaude,
68
+ parseClaudeLine,
69
+ };
@@ -0,0 +1,127 @@
1
+ const fs = require("node:fs/promises");
2
+ const path = require("node:path");
3
+ const { walk } = require("../fs-utils");
4
+
5
+ async function scanCodex(paths) {
6
+ const files = await walk(paths.codexSessionsDir, (p) => path.basename(p).startsWith("rollout-") && p.endsWith(".jsonl"));
7
+ const events = [];
8
+ for (const file of files) {
9
+ const text = await fs.readFile(file, "utf8").catch(() => "");
10
+ let lastTokenCountKey = null;
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const event = parseCodexLine(line, file);
13
+ if (!event) continue;
14
+ if (event._tokenCountKey) {
15
+ if (event._tokenCountKey === lastTokenCountKey) continue;
16
+ lastTokenCountKey = event._tokenCountKey;
17
+ delete event._tokenCountKey;
18
+ }
19
+ events.push(event);
20
+ }
21
+ }
22
+ return {
23
+ provider: "codex",
24
+ files: files.length,
25
+ events,
26
+ };
27
+ }
28
+
29
+ function parseCodexLine(line, filePath) {
30
+ if (!line || !line.trim()) return null;
31
+ let obj;
32
+ try {
33
+ obj = JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ const tokenCount = findTokenCount(obj);
38
+ if (tokenCount) return tokenCountToEvent(obj, tokenCount, filePath);
39
+
40
+ const response = findResponse(obj);
41
+ if (!response || !response.usage) return null;
42
+ const usage = response.usage;
43
+ const inputTokens = num(usage.input_tokens || usage.prompt_tokens);
44
+ const outputTokens = num(usage.output_tokens || usage.completion_tokens);
45
+ const cachedInputTokens = num(
46
+ usage.cached_input_tokens ||
47
+ usage.cache_read_input_tokens ||
48
+ usage.input_tokens_details?.cached_tokens ||
49
+ usage.prompt_tokens_details?.cached_tokens,
50
+ );
51
+ const cacheCreationInputTokens = num(usage.cache_creation_input_tokens);
52
+ const reasoningOutputTokens = num(
53
+ usage.reasoning_output_tokens || usage.output_tokens_details?.reasoning_tokens,
54
+ );
55
+ const totalTokens =
56
+ num(usage.total_tokens) ||
57
+ inputTokens + outputTokens + cacheCreationInputTokens;
58
+ if (totalTokens === 0) return null;
59
+ return {
60
+ source: "codex",
61
+ model: response.model || obj.model || "unknown",
62
+ timestamp: obj.timestamp || obj.ts || obj.created_at || new Date().toISOString(),
63
+ projectRef: inferProjectRef(obj, filePath),
64
+ inputTokens,
65
+ outputTokens,
66
+ cachedInputTokens,
67
+ cacheCreationInputTokens,
68
+ reasoningOutputTokens,
69
+ totalTokens,
70
+ exact: true,
71
+ };
72
+ }
73
+
74
+ function findTokenCount(obj) {
75
+ if (obj.type === "event_msg" && obj.payload?.type === "token_count") return obj.payload.info || null;
76
+ if (obj.payload?.type === "token_count") return obj.payload.info || null;
77
+ return null;
78
+ }
79
+
80
+ function tokenCountToEvent(obj, info, filePath) {
81
+ const usage = info.last_token_usage || info.total_token_usage;
82
+ if (!usage) return null;
83
+ const inputTokens = num(usage.input_tokens);
84
+ const outputTokens = num(usage.output_tokens);
85
+ const cachedInputTokens = num(usage.cached_input_tokens || usage.cache_read_input_tokens);
86
+ const cacheCreationInputTokens = num(usage.cache_creation_input_tokens);
87
+ const reasoningOutputTokens = num(usage.reasoning_output_tokens);
88
+ const totalTokens = num(usage.total_tokens) || inputTokens + outputTokens + cacheCreationInputTokens;
89
+ if (totalTokens === 0) return null;
90
+ return {
91
+ source: "codex",
92
+ model: info.model || obj.model || "codex",
93
+ timestamp: obj.timestamp || obj.ts || obj.created_at || new Date().toISOString(),
94
+ projectRef: inferProjectRef(obj, filePath),
95
+ inputTokens,
96
+ outputTokens,
97
+ cachedInputTokens,
98
+ cacheCreationInputTokens,
99
+ reasoningOutputTokens,
100
+ totalTokens,
101
+ exact: true,
102
+ _tokenCountKey: JSON.stringify(info.total_token_usage || usage),
103
+ };
104
+ }
105
+
106
+ function findResponse(obj) {
107
+ if (obj.response) return obj.response;
108
+ if (obj.msg?.response) return obj.msg.response;
109
+ if (obj.event?.response) return obj.event.response;
110
+ if (obj.type === "response.completed" && obj.data?.response) return obj.data.response;
111
+ if (obj.msg?.type === "response.completed" && obj.msg?.data?.response) return obj.msg.data.response;
112
+ if (obj.msg?.type === "response.completed" && obj.msg?.response) return obj.msg.response;
113
+ return null;
114
+ }
115
+
116
+ function inferProjectRef(obj, filePath) {
117
+ return obj.cwd || obj.project || obj.project_ref || null;
118
+ }
119
+
120
+ function num(value) {
121
+ return Number.isFinite(Number(value)) && Number(value) > 0 ? Math.round(Number(value)) : 0;
122
+ }
123
+
124
+ module.exports = {
125
+ scanCodex,
126
+ parseCodexLine,
127
+ };
package/src/server.js ADDED
@@ -0,0 +1,117 @@
1
+ const http = require("node:http");
2
+ const fsp = require("node:fs/promises");
3
+ const path = require("node:path");
4
+ const { resolvePaths } = require("./paths");
5
+ const { syncUsage } = require("./sync");
6
+
7
+ async function serve(options = {}) {
8
+ const port = Number(options.port) || 7680;
9
+ const server = createServer(options);
10
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
11
+ process.stdout.write(`AI Token Usage Lite running at http://127.0.0.1:${port}\n`);
12
+ process.stdout.write(`Data: ${resolvePaths(options).usagePath}\n`);
13
+ }
14
+
15
+ function createServer(options = {}) {
16
+ const publicDir = path.resolve(__dirname, "../public");
17
+
18
+ return http.createServer(async (req, res) => {
19
+ try {
20
+ const pathname = requestPath(req);
21
+ if (isApiRoute(pathname, "/api/summary") && req.method === "GET") {
22
+ const result = await syncUsage(options);
23
+ send(res, 200, "application/json", JSON.stringify(result.data));
24
+ return;
25
+ }
26
+ if (isApiRoute(pathname, "/api/sync") && isSyncMethod(req.method)) {
27
+ const result = await syncUsage(options);
28
+ send(res, 200, "application/json", JSON.stringify(result.data));
29
+ return;
30
+ }
31
+ const filePath = staticFilePath(publicDir, pathname);
32
+ const content = await fsp.readFile(filePath);
33
+ send(res, 200, contentType(filePath), content);
34
+ } catch (err) {
35
+ handleError(res, req, err);
36
+ }
37
+ });
38
+ }
39
+
40
+ function send(res, status, type, body) {
41
+ res.writeHead(status, {
42
+ "Content-Type": type,
43
+ "Cache-Control": "no-cache",
44
+ });
45
+ res.end(body);
46
+ }
47
+
48
+ function sendJson(res, status, value) {
49
+ send(res, status, "application/json; charset=utf-8", JSON.stringify(value));
50
+ }
51
+
52
+ function isSyncMethod(method) {
53
+ return method === "POST" || method === "GET";
54
+ }
55
+
56
+ function requestPath(req) {
57
+ try {
58
+ return new URL(req.url || "/", "http://127.0.0.1").pathname;
59
+ } catch {
60
+ return "/";
61
+ }
62
+ }
63
+
64
+ function isApiRoute(pathname, suffix) {
65
+ const normalizedPath = stripTrailingSlash(pathname);
66
+ const normalizedSuffix = stripTrailingSlash(suffix);
67
+ return normalizedPath === normalizedSuffix || normalizedPath.endsWith(normalizedSuffix);
68
+ }
69
+
70
+ function stripTrailingSlash(value) {
71
+ if (!value || value === "/") return "/";
72
+ return value.replace(/\/+$/, "");
73
+ }
74
+
75
+ function staticFilePath(publicDir, pathname) {
76
+ if (!pathname || pathname === "/" || !path.extname(pathname) || path.basename(pathname) === "index.html") {
77
+ return path.join(publicDir, "index.html");
78
+ }
79
+ const normalizedPath = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
80
+ const filePath = path.join(publicDir, normalizedPath);
81
+ if (!filePath.startsWith(publicDir)) {
82
+ return path.join(publicDir, "index.html");
83
+ }
84
+ return filePath;
85
+ }
86
+
87
+ function handleError(res, req, err) {
88
+ const pathname = requestPath(req);
89
+ if (isApiRoute(pathname, "/api/summary")) {
90
+ sendJson(res, 500, {
91
+ error: "读取统计数据失败",
92
+ detail: err && err.message ? err.message : String(err),
93
+ });
94
+ return;
95
+ }
96
+ if (isApiRoute(pathname, "/api/sync")) {
97
+ sendJson(res, 500, {
98
+ error: "同步失败",
99
+ detail: err && err.message ? err.message : String(err),
100
+ });
101
+ return;
102
+ }
103
+ if (err && err.code === "ENOENT") {
104
+ send(res, 404, "text/plain; charset=utf-8", "Not found");
105
+ return;
106
+ }
107
+ send(res, 500, "text/plain; charset=utf-8", "Internal server error");
108
+ }
109
+
110
+ function contentType(filePath) {
111
+ if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
112
+ if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
113
+ if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
114
+ return "application/octet-stream";
115
+ }
116
+
117
+ module.exports = { serve, createServer };