mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Refactor error handling and localDb structure, and fix usage tracking bug.
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.3.91",
|
||||
"version": "0.3.94",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export async function POST(request) {
|
||||
// Merge new env with existing settings
|
||||
const newSettings = {
|
||||
...currentSettings,
|
||||
hasCompletedOnboarding: true,
|
||||
env: {
|
||||
...(currentSettings.env || {}),
|
||||
...env,
|
||||
|
||||
@@ -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
14
src/lib/dataDir.js
Normal 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();
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,20 +46,23 @@ 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}`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user