diff --git a/frontend/.env.example b/frontend/.env.example index aeb458e5b..afd5b3cfb 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -20,3 +20,12 @@ # Defaults to localhost — only override for non-local deployments. # DEER_FLOW_INTERNAL_GATEWAY_BASE_URL="http://localhost:8001" # DEER_FLOW_TRUSTED_ORIGINS="http://localhost:3000,http://localhost:2026" + +# Extra hosts allowed to load Next.js dev-server resources (`/_next/*`, fonts, +# HMR). Development only — production builds ignore it. +# Needed to open `pnpm dev` / `make docker-start` on anything other than +# localhost, such as a LAN address or a proxied hostname: without it Next.js +# answers those requests with 403, so the page renders server-side but never +# hydrates and no interaction (including the login form) works. +# Comma-separated; a full URL is reduced to its host. +# DEER_FLOW_DEV_ALLOWED_ORIGINS="192.168.1.10,*.local" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 33ba03568..1040ceed8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -128,6 +128,8 @@ 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. +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 - [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) diff --git a/frontend/next.config.js b/frontend/next.config.js index 7007d59fc..c1b984c2f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -3,6 +3,7 @@ * for Docker builds. */ import "./src/env.js"; +import { getAllowedDevOrigins } from "./src/dev-origins.js"; function getInternalServiceURL(envKey, fallbackURL) { const configured = process.env[envKey]?.trim(); @@ -25,6 +26,7 @@ const config = { defaultLocale: "en", }, devIndicators: false, + allowedDevOrigins: getAllowedDevOrigins(), async rewrites() { const rewrites = []; const gatewayURL = getInternalServiceURL( diff --git a/frontend/src/dev-origins.js b/frontend/src/dev-origins.js new file mode 100644 index 000000000..b092a9a6d --- /dev/null +++ b/frontend/src/dev-origins.js @@ -0,0 +1,59 @@ +/** + * Hosts allowed to load Next.js dev-server resources (`/_next/*`, + * `/__nextjs_font/*`, HMR) from an origin other than the one `pnpm dev` was + * started on. + * + * Next.js answers those requests with 403 unless the host is listed, so a dev + * stack opened on a LAN address or a proxied hostname serves the SSR HTML but + * never hydrates: the page renders and nothing on it responds. + * + * Dev-only — Next ignores `allowedDevOrigins` in production builds. + */ + +/** + * Reduce one entry to the bare host `allowedDevOrigins` matches against. + * + * Next matches on host alone, so an entry that still carries a scheme, port, or + * path matches nothing and leaves the caller with the same 403 they were trying + * to fix. Accept the URL people naturally copy out of the address bar. + * + * @param {string} value + * @returns {string} bare host, or `""` if the entry was empty + */ +function normalizeHost(value) { + let host = value.trim(); + if (!host) return ""; + + host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); + host = host.replace(/[/?#].*$/, ""); + + const bracketedIpv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(host); + if (bracketedIpv6) return bracketedIpv6[1]; + + // A bare IPv6 literal has several colons and no port to strip; only a single + // colon can be a `host:port` separator. + if ((host.match(/:/g) ?? []).length === 1) { + host = host.replace(/:\d+$/, ""); + } + return host; +} + +/** + * Parse a comma-separated host list into the shape `allowedDevOrigins` expects. + * + * @param {string | undefined} raw + * @returns {string[]} + */ +export function parseAllowedDevOrigins(raw) { + return (raw ?? "").split(",").map(normalizeHost).filter(Boolean); +} + +/** + * Read the configured hosts from the environment. + * + * @param {Record} [env] + * @returns {string[]} + */ +export function getAllowedDevOrigins(env = process.env) { + return parseAllowedDevOrigins(env.DEER_FLOW_DEV_ALLOWED_ORIGINS); +} diff --git a/frontend/tests/unit/dev-origins.test.ts b/frontend/tests/unit/dev-origins.test.ts new file mode 100644 index 000000000..382772330 --- /dev/null +++ b/frontend/tests/unit/dev-origins.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "@rstest/core"; + +import { getAllowedDevOrigins, parseAllowedDevOrigins } from "@/dev-origins"; + +describe("parseAllowedDevOrigins", () => { + test("returns an empty list when unset or empty", () => { + expect(parseAllowedDevOrigins(undefined)).toEqual([]); + expect(parseAllowedDevOrigins("")).toEqual([]); + expect(parseAllowedDevOrigins(" ")).toEqual([]); + }); + + test("splits a comma-separated list and trims each entry", () => { + expect(parseAllowedDevOrigins(" 192.168.1.10 , dev.example.com ")).toEqual([ + "192.168.1.10", + "dev.example.com", + ]); + }); + + test("drops empty entries from trailing or doubled commas", () => { + expect(parseAllowedDevOrigins("a.example,,b.example,")).toEqual([ + "a.example", + "b.example", + ]); + }); + + test("reduces a pasted URL to the bare host Next matches on", () => { + expect(parseAllowedDevOrigins("http://192.168.1.10:2026")).toEqual([ + "192.168.1.10", + ]); + expect(parseAllowedDevOrigins("https://dev.example.com/")).toEqual([ + "dev.example.com", + ]); + expect(parseAllowedDevOrigins("http://dev.example.com/login?x=1")).toEqual([ + "dev.example.com", + ]); + }); + + test("preserves wildcard patterns", () => { + expect(parseAllowedDevOrigins("*.local, *.example.com")).toEqual([ + "*.local", + "*.example.com", + ]); + }); + + test("strips the port from a bracketed IPv6 host without mangling the address", () => { + expect(parseAllowedDevOrigins("[::1]:2026")).toEqual(["::1"]); + expect(parseAllowedDevOrigins("http://[fe80::1]:3000")).toEqual([ + "fe80::1", + ]); + }); + + test("leaves a bare IPv6 literal intact", () => { + // Several colons and no port to strip — treating the last group as a port + // would corrupt the address. + expect(parseAllowedDevOrigins("fe80::1")).toEqual(["fe80::1"]); + }); +}); + +describe("getAllowedDevOrigins", () => { + test("reads DEER_FLOW_DEV_ALLOWED_ORIGINS", () => { + expect( + getAllowedDevOrigins({ DEER_FLOW_DEV_ALLOWED_ORIGINS: "192.168.1.10" }), + ).toEqual(["192.168.1.10"]); + }); + + test("defaults to an empty list, keeping localhost-only the default", () => { + expect(getAllowedDevOrigins({})).toEqual([]); + }); +});