Refactor error handling and localDb structure, and fix usage tracking bug.

This commit is contained in:
decolua
2026-04-16 10:58:35 +07:00
parent 3badf1cbb6
commit 75ad0bef8e
13 changed files with 162 additions and 109 deletions

View File

@@ -1,3 +1,18 @@
# v0.3.91 (2026-04-15)
## Features
- Add Kiro AWS Identity Center device flow for provider OAuth
- Add TTS (Text-to-Speech) core handler and TTS models config
- Add media providers dashboard page
- Add suggested models API endpoint
## Improvements
- Refactor error handling to config-driven approach with centralized error rules
- Refactor localDb and usageDb for cleaner structure
## Fixes
- Fix usage tracking bug
# v0.3.90 (2026-04-14)
## Features

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.3.91",
"version": "0.3.94",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -217,7 +217,7 @@ export default function ClaudeToolCard({
return [
{
filename: "~/.claude/settings.json",
content: JSON.stringify({ env }, null, 2),
content: JSON.stringify({ hasCompletedOnboarding: true, env }, null, 2),
},
];
};

View File

@@ -226,8 +226,30 @@ export default function APIPageClient({ machineId }) {
setTunnelLoading(true);
setTunnelStatus(null);
setTunnelProgress("Creating tunnel...");
// Poll download progress while enable request is pending
let polling = true;
const pollProgress = async () => {
while (polling) {
try {
const r = await fetch("/api/tunnel/status");
if (r.ok) {
const s = await r.json();
if (s.download?.downloading) {
setTunnelProgress(`Downloading cloudflared... ${s.download.progress}%`);
} else if (polling) {
setTunnelProgress("Creating tunnel...");
}
}
} catch { /* ignore */ }
await new Promise((r) => setTimeout(r, 1000));
}
};
pollProgress();
try {
const res = await fetch("/api/tunnel/enable", { method: "POST" });
polling = false;
const data = await res.json();
if (!res.ok) {
setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" });
@@ -246,6 +268,7 @@ export default function APIPageClient({ machineId }) {
} catch (error) {
setTunnelStatus({ type: "error", message: error.message });
} finally {
polling = false;
setTunnelLoading(false);
setTunnelProgress("");
}

View File

@@ -120,6 +120,7 @@ export async function POST(request) {
// Merge new env with existing settings
const newSettings = {
...currentSettings,
hasCompletedOnboarding: true,
env: {
...(currentSettings.env || {}),
...env,

View File

@@ -1,10 +1,12 @@
import { NextResponse } from "next/server";
import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager";
import { getDownloadStatus } from "@/lib/tunnel/cloudflared";
export async function GET() {
try {
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
return NextResponse.json({ tunnel, tailscale });
const download = getDownloadStatus();
return NextResponse.json({ tunnel, tailscale, download });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });

14
src/lib/dataDir.js Normal file
View File

@@ -0,0 +1,14 @@
import path from "path";
import os from "os";
const APP_NAME = "9router";
export function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR;
if (process.platform === "win32") {
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME);
}
return path.join(os.homedir(), `.${APP_NAME}`);
}
export const DATA_DIR = getDataDir();

View File

@@ -2,32 +2,12 @@ import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { v4 as uuidv4 } from "uuid";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import lockfile from "proper-lockfile";
import { DATA_DIR } from "@/lib/dataDir.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
function getAppName() {
return "9router";
}
function getUserDataDir() {
if (isCloud) return "/tmp";
if (process.env.DATA_DIR) return process.env.DATA_DIR;
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
}
return path.join(homeDir, `.${appName}`);
}
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json");
if (!isCloud && !fs.existsSync(DATA_DIR)) {

View File

@@ -1,8 +1,8 @@
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { DATA_DIR } from "@/lib/dataDir.js";
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
@@ -12,26 +12,6 @@ const DEFAULT_FLUSH_INTERVAL_MS = 5000;
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
const CONFIG_CACHE_TTL_MS = 5000;
const MAX_TOTAL_DB_SIZE = 50 * 1024 * 1024; // 50MB hard limit for total DB file
function getAppName() {
return "9router";
}
function getUserDataDir() {
if (isCloud) return "/tmp";
if (process.env.DATA_DIR) return process.env.DATA_DIR;
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
}
return path.join(homeDir, `.${appName}`);
}
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.json");
if (!isCloud && !fs.existsSync(DATA_DIR)) {

View File

@@ -4,15 +4,15 @@ import https from "https";
import os from "os";
import { execSync, spawn } from "child_process";
import { savePid, loadPid, clearPid } from "./state.js";
import { DATA_DIR } from "@/lib/dataDir.js";
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
const BIN_DIR = path.join(DATA_DIR, "bin");
const BINARY_NAME = "cloudflared";
const IS_WINDOWS = os.platform() === "win32";
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
const CLOUDFLARED_VERSION = "2026.3.0";
const GITHUB_BASE_URL = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
const PLATFORM_MAPPINGS = {
darwin: {
@@ -21,7 +21,8 @@ const PLATFORM_MAPPINGS = {
},
win32: {
x64: "cloudflared-windows-amd64.exe",
x32: "cloudflared-windows-386.exe"
ia32: "cloudflared-windows-386.exe",
arm64: "cloudflared-windows-386.exe"
},
linux: {
x64: "cloudflared-linux-amd64",
@@ -29,6 +30,13 @@ const PLATFORM_MAPPINGS = {
}
};
// Fallback order: prefer smallest/most-compatible binary per platform
const PLATFORM_FALLBACK = {
darwin: "cloudflared-darwin-amd64.tgz",
win32: "cloudflared-windows-386.exe",
linux: "cloudflared-linux-amd64"
};
function getDownloadUrl() {
const platform = os.platform();
const arch = os.arch();
@@ -38,12 +46,15 @@ function getDownloadUrl() {
throw new Error(`Unsupported platform: ${platform}`);
}
const binaryName = platformMapping[arch];
if (!binaryName) {
throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform];
return `${GITHUB_BASE_URL}/${binaryName}`;
}
return `${GITHUB_BASE_URL}/${binaryName}`;
// Download state — shared so status API can read it
const dlState = { downloading: false, progress: 0 };
export function getDownloadStatus() {
return { downloading: dlState.downloading, progress: dlState.progress };
}
function downloadFile(url, dest) {
@@ -51,7 +62,7 @@ function downloadFile(url, dest) {
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if ([301, 302].includes(response.statusCode)) {
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlinkSync(dest);
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
@@ -65,18 +76,34 @@ function downloadFile(url, dest) {
return;
}
const totalBytes = parseInt(response.headers["content-length"], 10) || 0;
let receivedBytes = 0;
dlState.downloading = true;
dlState.progress = 0;
response.on("data", (chunk) => {
receivedBytes += chunk.length;
if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100);
});
response.pipe(file);
file.on("finish", () => {
dlState.downloading = false;
dlState.progress = 100;
file.close(() => resolve(dest));
});
file.on("error", (err) => {
dlState.downloading = false;
dlState.progress = 0;
file.close();
fs.unlinkSync(dest);
reject(err);
});
}).on("error", (err) => {
dlState.downloading = false;
dlState.progress = 0;
file.close();
if (fs.existsSync(dest)) fs.unlinkSync(dest);
reject(err);
@@ -84,27 +111,66 @@ function downloadFile(url, dest) {
});
}
const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+
// Validate binary is executable on current platform and not truncated
function isValidBinary(filePath) {
try {
const stat = fs.statSync(filePath);
if (stat.size < MIN_BINARY_SIZE) return false;
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
const magic = buf.toString("hex");
if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ)
if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
return magic.startsWith("7f454c46"); // ELF (Linux)
} catch {
return false;
}
}
let downloadPromise = null;
export async function ensureCloudflared() {
if (downloadPromise) return downloadPromise;
downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; });
return downloadPromise;
}
async function _ensureCloudflared() {
if (!fs.existsSync(BIN_DIR)) {
fs.mkdirSync(BIN_DIR, { recursive: true });
}
if (fs.existsSync(BIN_PATH)) {
if (!IS_WINDOWS) {
fs.chmodSync(BIN_PATH, "755");
// Clean up incomplete downloads from previous runs
const tmpPath = `${BIN_PATH}.tmp`;
if (fs.existsSync(tmpPath)) {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
if (fs.existsSync(BIN_PATH)) {
if (!isValidBinary(BIN_PATH)) {
console.log("[cloudflared] Invalid binary detected, re-downloading...");
fs.unlinkSync(BIN_PATH);
} else {
if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755");
return BIN_PATH;
}
}
const url = getDownloadUrl();
const isArchive = url.endsWith(".tgz");
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH;
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath;
await downloadFile(url, downloadDest);
if (isArchive) {
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
fs.unlinkSync(downloadDest);
} else {
fs.renameSync(downloadDest, BIN_PATH);
}
if (!IS_WINDOWS) {

View File

@@ -1,8 +1,8 @@
import fs from "fs";
import path from "path";
import os from "os";
import { DATA_DIR } from "@/lib/dataDir.js";
const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel");
const TUNNEL_DIR = path.join(DATA_DIR, "tunnel");
const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
const CLOUDFLARED_PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
const TAILSCALE_PID_FILE = path.join(TUNNEL_DIR, "tailscale.pid");

View File

@@ -4,15 +4,16 @@ import os from "os";
import { execSync, spawn } from "child_process";
import { execWithPassword } from "@/mitm/dns/dnsConfig";
import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js";
import { DATA_DIR } from "@/lib/dataDir.js";
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
const BIN_DIR = path.join(DATA_DIR, "bin");
const IS_MAC = os.platform() === "darwin";
const IS_LINUX = os.platform() === "linux";
const IS_WINDOWS = os.platform() === "win32";
const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale");
// Custom socket for userspace-networking mode (no root required)
const TAILSCALE_DIR = path.join(os.homedir(), ".9router", "tailscale");
const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale");
export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock");
const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET];
@@ -281,7 +282,21 @@ async function installTailscaleWindows(log) {
/** Start tailscaled with sudo (TUN mode required for Funnel) */
export async function startDaemonWithPassword(sudoPassword) {
if (IS_WINDOWS) return;
if (IS_WINDOWS) {
// Windows: tailscale runs as a Windows Service, try to start it
try {
const bin = getTailscaleBin();
if (bin) {
execSync(`"${bin}" status --json`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
return; // Already running
}
} catch { /* not running */ }
try {
execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 });
await new Promise((r) => setTimeout(r, 3000));
} catch { /* may need admin, or already running */ }
return;
}
// Check if daemon already responds
try {
@@ -387,7 +402,7 @@ export function startLogin(hostname) {
clearTimeout(timeout);
const url = parseAuthUrl(output);
if (url) resolve({ authUrl: url });
else if (isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true });
else if (code === 0 || isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true });
else reject(new Error(`tailscale up exited with code ${code}`));
});
});

View File

@@ -2,53 +2,10 @@ import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { EventEmitter } from "events";
import path from "path";
import os from "os";
import fs from "fs";
import { fileURLToPath } from "url";
import { DATA_DIR } from "@/lib/dataDir.js";
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
// Get app name from root package.json config
function getAppName() {
if (isCloud) return "9router"; // Skip file system access in Workers
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Look for root package.json (monorepo root)
const rootPkgPath = path.resolve(__dirname, "../../../package.json");
try {
const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
return pkg.config?.appName || "9router";
} catch {
return "9router";
}
}
// Get user data directory based on platform
function getUserDataDir() {
if (isCloud) return "/tmp"; // Fallback for Workers
if (process.env.DATA_DIR) return process.env.DATA_DIR;
try {
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
} else {
// macOS & Linux: ~/.{appName}
return path.join(homeDir, `.${appName}`);
}
} catch (error) {
console.error("[usageDb] Failed to get user data directory:", error.message);
// Fallback to cwd if homedir fails
return path.join(process.cwd(), ".9router");
}
}
// Data file path - stored in user home directory
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json");
const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");