fix(frontend): support standalone demo APIs and runtime GitHub stars (#5302)

* fix(frontend): support standalone demo APIs and runtime GitHub stars

* Update API origin URL to use environment variables

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(frontend): align static demo tests with runtime origin

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
JeffJiang 2026-09-09 00:00:56 +08:00 committed by GitHub
parent 065f84f711
commit dfaeef3772
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 569 additions and 40 deletions

View File

@ -411,6 +411,14 @@ is opt-in: it fails fast when `frontend/.next` has no completed build.
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
For a read-only demo without the Gateway, run `make build-static` from `frontend/`,
then `HOSTNAME=127.0.0.1 PORT=3000 node --env-file=.env .next/standalone/server.js`
from the same directory. The build includes public demo assets and resolves
supported demo API reads locally; writes are unavailable. To display the homepage
GitHub star count, set `GITHUB_OAUTH_TOKEN` in `frontend/.env` before starting Node.
The token stays on the server; missing credentials or GitHub failures hide the
count. Restart Node after changing the token; no rebuild is needed.
#### LangGraph Studio (Optional)
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and

View File

@ -86,6 +86,16 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
`make build-static` creates a standalone read-only demo and copies `.next/static`
and `public` into the output. In static mode, `core/api/static-response.ts`
resolves Gateway REST reads with empty capability/catalog responses or existing
same-origin `/mock/api` fixtures; writes and unknown API routes fail locally.
The homepage client counter calls `/github-stars`, outside the Gateway proxy.
That dynamic route reads the server-only `GITHUB_OAUTH_TOKEN` at runtime, caches
GitHub data for one hour, and returns 204 when the count is unavailable. Start
the standalone server from `frontend/` with `node --env-file=.env
.next/standalone/server.js` to load the current credentials.
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
## Resources

View File

@ -30,3 +30,4 @@ format:
build-static:
NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true $(PNPM) build
@if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi
@if [ -d public ]; then mkdir -p .next/standalone/public && cp -R public/. .next/standalone/public/; fi

View File

@ -0,0 +1,42 @@
import { env } from "@/env";
// Resolve deployment credentials on requests, never while prerendering a build.
// The explicit fetch revalidate below still caches GitHub data for one hour.
export const revalidate = 0;
/**
* Return only the public star count using the server's runtime GitHub token.
* No input; missing credentials, upstream failures, or invalid counts yield 204
* so the header hides the counter. Stays outside nginx's /api Gateway proxy.
*/
export async function GET() {
const token = env.GITHUB_OAUTH_TOKEN;
const headers = { "Cache-Control": "no-store" };
if (!token) return new Response(null, { status: 204, headers });
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 },
},
);
if (response.ok) {
const data = (await response.json()) as { stargazers_count?: unknown };
if (
typeof data.stargazers_count === "number" &&
Number.isSafeInteger(data.stargazers_count) &&
data.stargazers_count >= 0
) {
return Response.json({ stars: data.stargazers_count }, { headers });
}
}
} catch {
// The counter is optional; do not return upstream errors or credentials.
}
return new Response(null, { status: 204, headers });
}

View File

@ -1,14 +1,14 @@
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import { DEFAULT_LOCALE, type Locale } from "@/core/i18n/locale";
import { getI18n } from "@/core/i18n/server";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { MobileNav } from "./mobile-nav";
import { StarCounter } from "./star-counter";
export type HeaderProps = {
className?: string;
@ -72,8 +72,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
>
<GitHubLogoIcon className="size-4" />
<span className="hidden sm:inline">Star on GitHub</span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && <StarCounter />}
</a>
</Button>
</div>
@ -87,39 +86,3 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
</header>
);
}
async function StarCounter() {
let stars = 10000; // Default value
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
next: {
revalidate: 3600,
},
},
);
if (response.ok) {
const data = await response.json();
stars = data.stargazers_count ?? stars; // Update stars if API response is valid
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
{stars && (
<NumberTicker className="font-mono tabular-nums" value={stars} />
)}
</>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { StarFilledIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import { NumberTicker } from "@/components/ui/number-ticker";
/**
* Add the runtime star count to a prerendered header without a client-side token.
* No props; hides the optional count while unavailable and cancels on unmount.
*/
export function StarCounter() {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
const controller = new AbortController();
// The root layout has no query provider; this one-shot decorative request
// leaves homepage rendering static and GitHub caching on the server.
void fetch("/github-stars", { signal: controller.signal })
.then(async (response) => {
if (!response.ok || response.status === 204) return;
const data = (await response.json()) as { stars?: unknown };
if (
!controller.signal.aborted &&
typeof data.stars === "number" &&
Number.isSafeInteger(data.stars) &&
data.stars >= 0
) {
setStars(data.stars);
}
})
.catch(() => {
// An unavailable optional counter must not break the GitHub link.
});
return () => controller.abort();
}, []);
if (stars === null) return null;
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
<NumberTicker className="font-mono tabular-nums" value={stars} />
</>
);
}

View File

@ -1,6 +1,8 @@
import { buildLoginUrl } from "@/core/auth/types";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { UnauthorizedError } from "./errors";
import { staticApiResponse } from "./static-response";
/** HTTP methods that the gateway's CSRFMiddleware checks. */
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
@ -61,6 +63,17 @@ export async function fetch(
): Promise<Response> {
const url = typeof input === "string" ? input : input.url;
// Static demos have no Gateway. Resolve REST calls before credentials,
// CSRF, or redirects; demo assets and explicit mock routes still use HTTP.
if (isStaticWebsiteOnly()) {
const response = await staticApiResponse(url, {
...init,
method:
init?.method ?? (typeof input === "string" ? "GET" : input.method),
});
if (response) return response;
}
// Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip
// it to mirror the gateway's ``should_check_csrf`` logic exactly.
let headers = init?.headers;

View File

@ -0,0 +1,114 @@
import { getBackendBaseURL } from "@/core/config";
import type { FeaturesResponse } from "@/core/features/api";
import type { UserMemory } from "@/core/memory/types";
/**
* Resolve Gateway REST calls for the read-only demo without contacting a backend.
* Returns null for assets, mock routes, and unrelated origins so normal fetching
* can continue. Settings reuse existing same-origin fixtures; unknown API routes
* and writes fail locally rather than silently leaking out to a configured Gateway.
*/
export async function staticApiResponse(
input: string,
init?: RequestInit,
): Promise<Response | null> {
const origin =
typeof window === "undefined"
? `http://${process.env.HOSTNAME ?? "127.0.0.1"}:${process.env.PORT ?? "3000"}`
: window.location.origin;
const url = new URL(input, origin);
const roots = [
new URL(`${getBackendBaseURL()}/api/`, origin),
new URL("/api/", origin),
];
const root = roots.find(
(candidate) =>
url.origin === candidate.origin &&
url.pathname.startsWith(candidate.pathname),
);
if (!root) return null;
init?.signal?.throwIfAborted();
const method = (init?.method ?? "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
return Response.json(
{ detail: "Unavailable in static demo mode" },
{ status: 405 },
);
}
const path = url.pathname.slice(root.pathname.length).replace(/\/$/, "");
// These routes already own the demo settings data; do not maintain a second copy.
if (["skills", "mcp/config", "integrations/lark/status"].includes(path)) {
return globalThis.fetch(new URL(`/mock/api/${path}`, origin).href, init);
}
let data: unknown;
switch (path) {
case "features":
data = {
agents_api: { enabled: false },
browser_control: { enabled: false },
mcp_tasks: { enabled: false },
subagent_batches: {
enabled: false,
repository_available: false,
worker_running: false,
max_running: 0,
},
} satisfies FeaturesResponse;
break;
case "channels/providers":
data = { enabled: false, providers: [] };
break;
case "channels/connections":
data = { connections: [] };
break;
case "agents":
data = { agents: [] };
break;
case "subagents":
data = { subagents: [] };
break;
case "suggestions/config":
data = { enabled: false, max_suggestions: 0 };
break;
case "memory":
case "memory/export": {
const empty = { summary: "", updatedAt: "" };
data = {
version: "1.0",
lastUpdated: "",
user: { workContext: empty, personalContext: empty, topOfMind: empty },
history: {
recentMonths: empty,
earlierContext: empty,
longTermBackground: empty,
},
facts: [],
} satisfies UserMemory;
break;
}
default:
// The LangGraph static client already supplies the demo transcript.
// There is no additional durable history or live token usage to fetch.
if (/^threads\/[^/]+\/messages\/page$/.test(path)) {
data = { data: [], has_more: false, next_before_seq: null };
} else if (/^threads\/[^/]+\/token-usage$/.test(path)) {
data = null;
} else if (
path === "scheduled-tasks" ||
/^scheduled-tasks\/[^/]+\/runs$/.test(path) ||
/^threads\/[^/]+\/scheduled-tasks$/.test(path) ||
/^threads\/[^/]+\/runs\/[^/]+\/events$/.test(path)
) {
data = [];
} else {
return Response.json(
{ detail: "Unavailable in static demo mode" },
{ status: 404 },
);
}
}
return method === "HEAD" ? new Response(null) : Response.json(data);
}

View File

@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { GET, revalidate } from "@/app/github-stars/route";
const { env } = rs.hoisted(() => ({ env: { GITHUB_OAUTH_TOKEN: "" } }));
rs.mock("@/env", () => ({ env }));
beforeEach(() => {
env.GITHUB_OAUTH_TOKEN = "";
});
afterEach(() => {
rs.restoreAllMocks();
});
describe("runtime GitHub stars", () => {
it("does not prerender or fetch when no runtime token is configured", async () => {
const fetch = rs.spyOn(globalThis, "fetch");
const response = await GET();
expect(revalidate).toBe(0);
expect(response.status).toBe(204);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(fetch).not.toHaveBeenCalled();
});
it("reads the server token at request time and exposes only the count", async () => {
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
const fetch = rs.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json({
stargazers_count: 43210,
private_data: "not-for-the-client",
}),
);
const response = await GET();
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/bytedance/deer-flow",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer runtime-test-token",
}),
next: { revalidate: 3600 },
}),
);
expect(await response.json()).toEqual({ stars: 43210 });
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("hides the count when GitHub rejects the token", async () => {
env.GITHUB_OAUTH_TOKEN = "invalid-test-token";
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 401 }),
);
expect((await GET()).status).toBe(204);
});
it("hides the count on network failure without returning error details", async () => {
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
rs.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("private upstream details"),
);
const response = await GET();
expect(response.status).toBe(204);
expect(await response.text()).toBe("");
});
it.each([{}, { stargazers_count: -1 }, { stargazers_count: "81900" }])(
"hides invalid GitHub data: %j",
async (body) => {
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
rs.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(body));
expect((await GET()).status).toBe(204);
},
);
});

View File

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { act, cleanup, render, screen } from "@testing-library/react";
import { StarCounter } from "@/components/landing/star-counter";
rs.mock("@/components/ui/number-ticker", () => ({
NumberTicker: ({ value }: { value: number }) => <span>{value}</span>,
}));
afterEach(() => {
cleanup();
rs.restoreAllMocks();
});
describe("landing star counter", () => {
it("loads the runtime count without sending a GitHub token from the browser", async () => {
const fetch = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(Response.json({ stars: 43210 }));
render(<StarCounter />);
await screen.findByText("43210");
expect(fetch).toHaveBeenCalledWith("/github-stars", {
signal: expect.any(AbortSignal),
});
});
it("keeps the count hidden when the runtime reports no available count", async () => {
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 204 }),
);
const { container } = render(<StarCounter />);
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toBe("");
});
it("keeps the count hidden when the frontend endpoint is unreachable", async () => {
rs.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("Network unavailable"),
);
const { container } = render(<StarCounter />);
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toBe("");
});
it("cancels the request when the header unmounts", () => {
const fetch = rs.spyOn(globalThis, "fetch").mockImplementation(
() =>
new Promise(() => {
// Keep the request pending until the component cancels it.
}),
);
const { unmount } = render(<StarCounter />);
const signal = fetch.mock.calls[0]?.[1]?.signal;
unmount();
expect(signal?.aborted).toBe(true);
});
});

View File

@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { listAgents } from "@/core/agents/api";
import { fetch as apiFetch } from "@/core/api/fetcher";
import {
listChannelConnections,
listChannelProviders,
} from "@/core/channels/api";
import { fetchFeatures } from "@/core/features/api";
import { loadLarkIntegrationStatus } from "@/core/integrations/lark/api";
import { loadMCPConfig } from "@/core/mcp/api";
import { loadMemory } from "@/core/memory/api";
import {
createScheduledTask,
fetchScheduledTaskRuns,
fetchScheduledTasks,
fetchThreadScheduledTasks,
} from "@/core/scheduled-tasks/api";
import { loadSkills } from "@/core/skills/api";
import { listSubagents } from "@/core/subagents/api";
import { loadSuggestionsConfig } from "@/core/suggestions/api";
import { fetchSubtaskSteps } from "@/core/tasks/api";
import { fetchThreadTokenUsage } from "@/core/threads/api";
const { env } = rs.hoisted(() => ({
env: {
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "true",
NEXT_PUBLIC_BACKEND_BASE_URL: "",
},
}));
rs.mock("@/env", () => ({ env }));
const network = rs.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Response.json({}),
);
beforeEach(() => {
rs.stubEnv("HOSTNAME", undefined);
rs.stubEnv("PORT", undefined);
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "true";
env.NEXT_PUBLIC_BACKEND_BASE_URL = "";
network.mockReset();
network.mockResolvedValue(Response.json({}));
rs.stubGlobal("fetch", network);
});
afterEach(() => {
rs.unstubAllEnvs();
rs.unstubAllGlobals();
});
describe("static website API requests", () => {
it("loads optional capabilities and empty catalogs without the Gateway", async () => {
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: false },
browser_control: { enabled: false },
mcp_tasks: { enabled: false },
subagent_batches: { repository_available: false, worker_running: false },
});
await expect(listChannelProviders()).resolves.toEqual({
enabled: false,
providers: [],
});
await expect(listChannelConnections()).resolves.toEqual([]);
await expect(listAgents()).resolves.toEqual([]);
await expect(listSubagents()).resolves.toEqual([]);
await expect(loadSuggestionsConfig()).resolves.toMatchObject({
enabled: false,
});
await expect(fetchScheduledTasks()).resolves.toEqual([]);
await expect(fetchThreadScheduledTasks("demo / thread")).resolves.toEqual(
[],
);
await expect(fetchScheduledTaskRuns("task / id")).resolves.toEqual([]);
await expect(fetchSubtaskSteps("thread", "run", "task")).resolves.toEqual(
[],
);
await expect(fetchThreadTokenUsage("demo")).resolves.toBeNull();
const history = await apiFetch("/api/threads/demo/messages/page?limit=50");
expect(await history.json()).toEqual({
data: [],
has_more: false,
next_before_seq: null,
});
await expect(loadMemory()).resolves.toMatchObject({
facts: [],
user: {},
history: {},
});
expect(network).not.toHaveBeenCalled();
});
it("uses the existing same-origin settings fixtures even with a configured Gateway", async () => {
env.NEXT_PUBLIC_BACKEND_BASE_URL = "https://gateway.example/prefix";
network.mockResolvedValueOnce(Response.json({ skills: [] }));
await expect(loadSkills()).resolves.toEqual([]);
network.mockResolvedValueOnce(Response.json({ mcp_servers: {} }));
await expect(loadMCPConfig()).resolves.toEqual({ mcp_servers: {} });
network.mockResolvedValueOnce(Response.json({ installed: false }));
await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
installed: false,
});
expect(network.mock.calls.map(([url]) => url)).toEqual([
"http://127.0.0.1:3000/mock/api/skills",
"http://127.0.0.1:3000/mock/api/mcp/config",
"http://127.0.0.1:3000/mock/api/integrations/lark/status",
]);
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: false },
});
expect(network).toHaveBeenCalledTimes(3);
});
it("rejects writes and unsupported endpoints locally instead of reporting fake success", async () => {
await expect(
createScheduledTask({
context_mode: "fresh_thread_per_run",
title: "Demo",
prompt: "Demo",
schedule_type: "once",
schedule_spec: {},
timezone: "UTC",
}),
).rejects.toThrow("Unavailable in static demo mode");
expect((await apiFetch("/api/new-feature")).status).toBe(404);
expect(
(
await apiFetch(
new Request("http://127.0.0.1:3000/api/subagents", {
method: "DELETE",
}),
)
).status,
).toBe(405);
expect(network).not.toHaveBeenCalled();
});
it.each([
["demo.internal", undefined, "http://demo.internal:3000"],
[undefined, "4000", "http://127.0.0.1:4000"],
["demo.internal", "4000", "http://demo.internal:4000"],
])(
"uses the server origin with HOSTNAME=%s and PORT=%s",
async (hostname, port, origin) => {
rs.stubEnv("HOSTNAME", hostname);
rs.stubEnv("PORT", port);
await apiFetch("/api/skills");
expect(network).toHaveBeenCalledWith(
`${origin}/mock/api/skills`,
expect.anything(),
);
network.mockClear();
const response = await apiFetch(
new Request(`${origin}/api/subagents`, { method: "DELETE" }),
);
expect(response.status).toBe(405);
expect(network).not.toHaveBeenCalled();
},
);
it("leaves demo assets and unrelated external requests intact", async () => {
await apiFetch("/demo/threads/demo/thread.json");
await apiFetch("https://external.example/api/features");
expect(network).toHaveBeenCalledTimes(2);
});
it("uses the current browser origin for fixtures and respects cancellation", async () => {
rs.stubEnv("HOSTNAME", "demo.internal");
rs.stubEnv("PORT", "4000");
rs.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } });
await apiFetch("/api/skills");
expect(network).toHaveBeenCalledWith(
"http://127.0.0.1:3000/mock/api/skills",
expect.anything(),
);
network.mockClear();
const controller = new AbortController();
controller.abort();
await expect(
apiFetch("/api/features", { signal: controller.signal }),
).rejects.toThrow();
expect(
await (await apiFetch("/api/features", { method: "HEAD" })).text(),
).toBe("");
expect(network).not.toHaveBeenCalled();
});
it("preserves normal backend requests unless the flag is exactly true", async () => {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "false";
network.mockResolvedValue(Response.json({ agents_api: { enabled: true } }));
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: true },
});
expect(network).toHaveBeenCalledWith(
"/api/features",
expect.objectContaining({ credentials: "include" }),
);
});
});