fix(models): apply blocked providers filter to non-chat catalog models (#1752)

This commit is contained in:
diegosouzapw
2026-04-29 00:45:18 -03:00
parent 38d89c519c
commit b96b85f675
10 changed files with 780 additions and 7 deletions

View File

@@ -4,6 +4,7 @@
### 🐛 Bug Fixes
- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752)
- **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748)
---

246
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -16,6 +16,7 @@
"@lobehub/icons": "^5.5.4",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",
@@ -2322,6 +2323,247 @@
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz",
"integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==",
"license": "(MIT OR Apache-2.0)",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@ngrok/ngrok-android-arm64": "1.7.0",
"@ngrok/ngrok-darwin-arm64": "1.7.0",
"@ngrok/ngrok-darwin-universal": "1.7.0",
"@ngrok/ngrok-darwin-x64": "1.7.0",
"@ngrok/ngrok-freebsd-x64": "1.7.0",
"@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0",
"@ngrok/ngrok-linux-arm64-gnu": "1.7.0",
"@ngrok/ngrok-linux-arm64-musl": "1.7.0",
"@ngrok/ngrok-linux-x64-gnu": "1.7.0",
"@ngrok/ngrok-linux-x64-musl": "1.7.0",
"@ngrok/ngrok-win32-arm64-msvc": "1.7.0",
"@ngrok/ngrok-win32-ia32-msvc": "1.7.0",
"@ngrok/ngrok-win32-x64-msvc": "1.7.0"
}
},
"node_modules/@ngrok/ngrok-android-arm64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz",
"integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-arm64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz",
"integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-universal": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz",
"integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-x64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz",
"integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-freebsd-x64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz",
"integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm-gnueabihf": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz",
"integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm64-gnu": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz",
"integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm64-musl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz",
"integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-x64-gnu": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz",
"integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-x64-musl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz",
"integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-arm64-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz",
"integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-ia32-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz",
"integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-x64-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz",
"integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -110,6 +110,7 @@
"@lobehub/icons": "^5.5.4",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",

View File

@@ -63,6 +63,26 @@ type TailscaleTunnelStatus = {
pid: number | null;
};
type NgrokTunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "needs_auth"
| "starting"
| "running"
| "error";
type NgrokTunnelStatus = {
supported: boolean;
installed: boolean;
running: boolean;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: NgrokTunnelPhase;
lastError: string | null;
};
type TunnelNotice = {
type: "success" | "error" | "info";
message: string;
@@ -105,6 +125,11 @@ export default function APIPageClient({ machineId }) {
const [tailscalePassword, setTailscalePassword] = useState("");
const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true);
const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true);
const [ngrokStatus, setNgrokStatus] = useState<NgrokTunnelStatus | null>(null);
const [ngrokBusy, setNgrokBusy] = useState(false);
const [ngrokNotice, setNgrokNotice] = useState<TunnelNotice | null>(null);
const [ngrokToken, setNgrokToken] = useState("");
const [showNgrokTunnel, setShowNgrokTunnel] = useState(true);
const { copied, copy } = useCopyToClipboard();
@@ -201,6 +226,35 @@ export default function APIPageClient({ machineId }) {
[translateOrFallback]
);
const fetchNgrokStatus = useCallback(
async (silent = false) => {
try {
const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" });
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status")
);
}
setNgrokStatus(data);
return data as NgrokTunnelStatus;
} catch (error) {
if (!silent) {
setNgrokNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"),
});
}
return null;
}
},
[translateOrFallback]
);
useEffect(() => {
Promise.allSettled([
loadCloudSettings(),
@@ -209,10 +263,11 @@ export default function APIPageClient({ machineId }) {
fetchSearchProviders(),
fetchCloudflaredStatus(true),
fetchTailscaleStatus(true),
fetchNgrokStatus(true),
]).finally(() => {
setLoading(false);
});
}, [fetchCloudflaredStatus, fetchTailscaleStatus]);
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
const fetchModels = async () => {
try {
@@ -502,6 +557,52 @@ export default function APIPageClient({ machineId }) {
}
};
const handleNgrokAction = async (action: "enable" | "disable") => {
setNgrokBusy(true);
setNgrokNotice(null);
try {
const res = await fetch("/api/tunnels/ngrok", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel")
);
}
if (data?.status) {
setNgrokStatus(data.status);
}
setNgrokNotice({
type: "success",
message:
action === "enable"
? translateOrFallback("ngrokStarted", "ngrok tunnel started")
: translateOrFallback("ngrokStopped", "ngrok tunnel stopped"),
});
if (action === "enable") {
setNgrokToken("");
}
} catch (error) {
setNgrokNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"),
});
} finally {
setNgrokBusy(false);
await fetchNgrokStatus(true);
}
};
const waitForTailscale = useCallback(
async (
predicate: (status: TailscaleTunnelStatus) => boolean,
@@ -891,6 +992,42 @@ export default function APIPageClient({ machineId }) {
"Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use."
);
const ngrokPhase = ngrokStatus?.phase || "not_installed";
const ngrokPhaseMeta: Record<NgrokTunnelPhase, { label: string; className: string }> = {
running: {
label: translateOrFallback("ngrokRunning", "Running"),
className: "bg-green-500/10 border-green-500/30 text-green-400",
},
starting: {
label: translateOrFallback("ngrokStarting", "Starting"),
className: "bg-blue-500/10 border-blue-500/30 text-blue-400",
},
stopped: {
label: translateOrFallback("ngrokStoppedState", "Stopped"),
className: "bg-surface border-border/70 text-text-muted",
},
needs_auth: {
label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"),
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
},
not_installed: {
label: translateOrFallback("ngrokNotInstalled", "Not installed"),
className: "bg-surface border-border/70 text-text-muted",
},
unsupported: {
label: translateOrFallback("ngrokUnsupported", "Unsupported"),
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
},
error: {
label: translateOrFallback("ngrokError", "Error"),
className: "bg-red-500/10 border-red-500/30 text-red-400",
},
};
const ngrokActionLabel = ngrokStatus?.running
? translateOrFallback("ngrokDisable", "Stop Tunnel")
: translateOrFallback("ngrokEnable", "Enable Tunnel");
const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel.");
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
@@ -1229,6 +1366,120 @@ export default function APIPageClient({ machineId }) {
</div>
</div>
)}
{showNgrokTunnel && (
<div
className={`${showCloudflaredTunnel || showTailscaleFunnel ? "mt-4 " : ""}rounded-xl border border-border/70 bg-surface/40 p-4`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("ngrokTitle", "ngrok Tunnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${ngrokPhaseMeta[ngrokPhase].className}`}
>
{ngrokPhaseMeta[ngrokPhase].label}
</span>
</div>
</div>
{ngrokStatus?.supported !== false && (
<Button
size="sm"
variant={ngrokStatus?.running ? "secondary" : "primary"}
icon={ngrokStatus?.running ? "public_off" : "public"}
onClick={() => handleNgrokAction(ngrokStatus?.running ? "disable" : "enable")}
loading={ngrokBusy}
className={
ngrokStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600"
}
>
{ngrokActionLabel}
</Button>
)}
</div>
{ngrokNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
ngrokNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: ngrokNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{ngrokNotice.type === "success"
? "check_circle"
: ngrokNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{ngrokNotice.message}</span>
<button
onClick={() => setNgrokNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{ngrokUrlNotice}</p>
{ngrokStatus?.phase === "needs_auth" && (
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted">
{translateOrFallback(
"ngrokAuthTokenLabel",
"Authtoken (Required if NGROK_AUTHTOKEN not set)"
)}
</label>
<Input
type="password"
value={ngrokToken}
onChange={(event) => setNgrokToken(event.target.value)}
placeholder={translateOrFallback(
"ngrokAuthTokenPlaceholder",
"Enter your ngrok authtoken"
)}
disabled={ngrokBusy}
className="font-mono text-sm"
/>
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={ngrokStatus?.apiUrl || ""}
readOnly
placeholder="https://your-tunnel.ngrok-free.app/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "ngrok_url" ? "check" : "content_copy"}
onClick={() => ngrokStatus?.apiUrl && copy(ngrokStatus.apiUrl, "ngrok_url")}
disabled={!ngrokStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "ngrok_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{ngrokStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("ngrokLastError", "Last error: {error}", {
error: ngrokStatus.lastError,
})}
</p>
)}
</div>
</div>
)}
</Card>
<Card>

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel";
export const dynamic = "force-dynamic";
const actionSchema = z.object({
action: z.enum(["enable", "disable"]),
authToken: z.string().optional(),
});
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
try {
const status = await getNgrokTunnelStatus();
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status",
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(actionSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
}
const parsed = validation.data;
try {
const status =
parsed.action === "enable"
? await startNgrokTunnel(parsed.authToken)
: await stopNgrokTunnel();
return NextResponse.json({
success: true,
action: parsed.action,
status,
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to update ngrok tunnel",
},
{ status: 500 }
);
}
}

View File

@@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse(
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider;
// FIX #1752: Ensure blocked providers are not returned for non-chat models
if (
blockedProviders.has(alias) ||
blockedProviders.has(canonicalProviderId) ||
blockedProviders.has(provider)
) {
return false;
}
return activeAliases.has(alias) || activeAliases.has(provider);
};

View File

@@ -1980,7 +1980,24 @@
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing",
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)"
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)",
"ngrokTitle": "ngrok Tunnel",
"ngrokRunning": "Running",
"ngrokStarting": "Starting",
"ngrokStoppedState": "Stopped",
"ngrokNeedsAuth": "Needs Auth",
"ngrokNotInstalled": "Not installed",
"ngrokUnsupported": "Unsupported",
"ngrokError": "Error",
"ngrokEnable": "Enable Tunnel",
"ngrokDisable": "Stop Tunnel",
"ngrokUrlNotice": "Creates a public ngrok tunnel.",
"ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)",
"ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken",
"ngrokLastError": "Last error: {error}",
"ngrokStarted": "ngrok tunnel started",
"ngrokStopped": "ngrok tunnel stopped",
"ngrokRequestFailed": "Failed to update ngrok tunnel"
},
"endpoints": {
"tabProxy": "Endpoint Proxy",

View File

@@ -1902,7 +1902,24 @@
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing",
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)"
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)",
"ngrokTitle": "Túnel ngrok",
"ngrokRunning": "Rodando",
"ngrokStarting": "Iniciando",
"ngrokStoppedState": "Parado",
"ngrokNeedsAuth": "Requer Autenticação",
"ngrokNotInstalled": "Não instalado",
"ngrokUnsupported": "Não suportado",
"ngrokError": "Erro",
"ngrokEnable": "Habilitar Túnel",
"ngrokDisable": "Parar Túnel",
"ngrokUrlNotice": "Cria um túnel público ngrok.",
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
"ngrokLastError": "Último erro: {error}",
"ngrokStarted": "Túnel ngrok iniciado",
"ngrokStopped": "Túnel ngrok parado",
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
},
"endpoints": {
"tabProxy": "Proxy de terminal",

View File

@@ -1874,7 +1874,24 @@
"tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.",
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing"
"tailscaleInstalling": "Installing",
"ngrokTitle": "Túnel ngrok",
"ngrokRunning": "Rodando",
"ngrokStarting": "Iniciando",
"ngrokStoppedState": "Parado",
"ngrokNeedsAuth": "Requer Autenticação",
"ngrokNotInstalled": "Não instalado",
"ngrokUnsupported": "Não suportado",
"ngrokError": "Erro",
"ngrokEnable": "Habilitar Túnel",
"ngrokDisable": "Parar Túnel",
"ngrokUrlNotice": "Cria um túnel público ngrok.",
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
"ngrokLastError": "Último erro: {error}",
"ngrokStarted": "Túnel ngrok iniciado",
"ngrokStopped": "Túnel ngrok parado",
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
},
"endpoints": {
"tabProxy": "Endpoint Proxy",

142
src/lib/ngrokTunnel.ts Normal file
View File

@@ -0,0 +1,142 @@
import { getRuntimePorts } from "@/lib/runtime/ports";
export type TunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "needs_auth"
| "starting"
| "running"
| "error";
export type NgrokTunnelStatus = {
supported: boolean;
installed: boolean;
running: boolean;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: TunnelPhase;
lastError: string | null;
};
// Next.js hot-reloading safe global storage for the listener
const globalForNgrok = globalThis as unknown as {
__ngrokListener: any;
};
let startPromise: Promise<NgrokTunnelStatus> | null = null;
function getLocalTargetUrl() {
const { apiPort } = getRuntimePorts();
return `http://127.0.0.1:${apiPort}`;
}
function getTunnelApiUrl(publicUrl: string | null) {
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
}
export async function getNgrokTunnelStatus(): Promise<NgrokTunnelStatus> {
const targetUrl = getLocalTargetUrl();
const tokenAvailable = !!(
process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== ""
);
const listener = globalForNgrok.__ngrokListener;
let currentUrl = null;
if (listener) {
try {
currentUrl = typeof listener.url === "function" ? listener.url() : listener.url;
} catch {
// Ignored
}
}
return {
supported: true,
installed: true,
running: currentUrl !== null,
publicUrl: currentUrl || null,
apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null,
targetUrl,
phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running",
lastError: null,
};
}
export async function startNgrokTunnel(inputAuthToken?: string): Promise<NgrokTunnelStatus> {
const current = await getNgrokTunnelStatus();
if (current.running) return current;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
const authToken =
inputAuthToken && inputAuthToken.trim() !== ""
? inputAuthToken.trim()
: process.env.NGROK_AUTHTOKEN;
if (!authToken) {
return {
...(await getNgrokTunnelStatus()),
phase: "needs_auth",
lastError: "An ngrok authtoken is required.",
};
}
// Dynamically import ngrok so it doesn't break environments where native build fails if not used
const ngrok = await import("@ngrok/ngrok");
const targetUrl = getLocalTargetUrl();
const listenerOptions: any = { addr: targetUrl };
if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) {
listenerOptions.authtoken_from_env = true;
} else {
listenerOptions.authtoken = authToken;
}
const listener = await ngrok.forward(listenerOptions);
globalForNgrok.__ngrokListener = listener;
let url = null;
try {
url = typeof listener.url === "function" ? listener.url() : listener.url;
} catch {
// Ignored
}
if (!url) {
await stopNgrokTunnel();
throw new Error("ngrok did not return a public URL.");
}
return await getNgrokTunnelStatus();
} catch (error) {
return {
...(await getNgrokTunnelStatus()),
phase: "error",
lastError: error instanceof Error ? error.message : String(error),
};
} finally {
startPromise = null;
}
})();
return startPromise;
}
export async function stopNgrokTunnel(): Promise<NgrokTunnelStatus> {
const listener = globalForNgrok.__ngrokListener;
if (listener) {
try {
if (typeof listener.close === "function") {
await listener.close();
}
} catch (e) {
// Ignore close errors
}
globalForNgrok.__ngrokListener = undefined;
}
return await getNgrokTunnelStatus();
}