feat: support zero-config LAN access for local development

Auto-rewrite loopback API URLs to current page host in browser
Allow CORS for private LAN IPs when localhost origin is configured
Remove display limit for related Pokémon in detail view
This commit is contained in:
2026-05-13 10:55:31 +08:00
parent 231a7bb313
commit 8628bdf68b
6 changed files with 139 additions and 10 deletions

View File

@@ -197,7 +197,10 @@ const sessionCookieName = 'pokopia_session';
const rememberedSessionDays = 30;
const sessionOnlySessionDays = 1;
function configuredCorsOrigin(): true | string | string[] {
type CorsOriginCallback = (error: Error | null, origin: boolean | string) => void;
type CorsOriginResolver = (origin: string | undefined, callback: CorsOriginCallback) => void;
function configuredCorsOrigin(): true | CorsOriginResolver {
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
if (!rawOrigin) {
return true;
@@ -208,7 +211,78 @@ function configuredCorsOrigin(): true | string | string[] {
.map((origin) => origin.trim())
.filter(Boolean);
return origins.length <= 1 ? (origins[0] ?? true) : origins;
if (origins.length === 0) {
return true;
}
return (origin, callback) => {
callback(null, isAllowedCorsOrigin(origin, origins) ? (origin ?? false) : false);
};
}
function isAllowedCorsOrigin(origin: string | undefined, configuredOrigins: string[]): boolean {
if (!origin) {
return false;
}
if (configuredOrigins.includes(origin)) {
return true;
}
return configuredOrigins.some((configuredOrigin) => isLocalFrontendOriginAlias(origin, configuredOrigin));
}
function isLocalFrontendOriginAlias(origin: string, configuredOrigin: string): boolean {
try {
const requestUrl = new URL(origin);
const configuredUrl = new URL(configuredOrigin);
return (
requestUrl.protocol === configuredUrl.protocol &&
effectivePort(requestUrl) === effectivePort(configuredUrl) &&
isLoopbackHost(configuredUrl.hostname) &&
isLocalDevelopmentHost(requestUrl.hostname)
);
} catch {
return false;
}
}
function effectivePort(url: URL): string {
if (url.port) {
return url.port;
}
return url.protocol === 'https:' ? '443' : '80';
}
function isLoopbackHost(hostname: string): boolean {
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
}
function isLocalDevelopmentHost(hostname: string): boolean {
return (
isLoopbackHost(hostname) ||
isPrivateIpv4Host(hostname) ||
['host.docker.internal', 'frontend', 'frontend_gateway'].includes(hostname)
);
}
function isPrivateIpv4Host(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [first, second] = parts;
return (
first === 10 ||
first === 127 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 169 && second === 254)
);
}
await app.register(cors, {