deer-flow/frontend/src/dev-origins.js
DeepCold e17aff57a0
fix(frontend): allow dev-server access from non-localhost hosts (#4471)
Opening the dev stack on a LAN address or a proxied hostname serves the
SSR HTML but never hydrates: Next.js answers /_next/*, /__nextjs_font/*,
and HMR with 403 for any host it was not started on. The page renders, so
it looks up — but no client handler is attached, and the login form's
onSubmit never fires. It reads as "login is broken" rather than as an
asset problem, and the only clue is a warning in the dev-server log.

Wire Next's allowedDevOrigins to a new DEER_FLOW_DEV_ALLOWED_ORIGINS env
var. Unset by default, so the localhost-only default is unchanged; it is
also dev-only, as Next ignores allowedDevOrigins in production builds.

Entries are reduced to the bare host that allowedDevOrigins matches
against, since an entry pasted from the address bar as
"http://192.168.1.10:2026" would otherwise match nothing and leave the
operator with the same 403 they were trying to fix.

Reported in #54 and #203.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:34:50 +08:00

60 lines
1.9 KiB
JavaScript

/**
* 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<string, string | undefined>} [env]
* @returns {string[]}
*/
export function getAllowedDevOrigins(env = process.env) {
return parseAllowedDevOrigins(env.DEER_FLOW_DEV_ALLOWED_ORIGINS);
}