Route main SSE traffic to backend

This commit is contained in:
2569718930@qq.com
2026-06-10 12:31:14 +08:00
parent afdfe03174
commit 98026e510a
4 changed files with 75 additions and 3 deletions
+13
View File
@@ -9,6 +9,19 @@ server {
proxy_buffers 8 16k; proxy_buffers 8 16k;
proxy_busy_buffers_size 32k; proxy_busy_buffers_size 32k;
location /api/events {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
proxy_pass http://127.0.0.1:3001; proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -49,6 +49,49 @@ function buildRuntimeContext(extra: Record<string, unknown>) {
}; };
} }
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
text.includes("<html ") ||
text.includes("cloudflare")
);
}
function extractFeedbackErrorMessage(raw: string) {
try {
const parsed = JSON.parse(String(raw || "")) as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const value = [parsed.detail, parsed.error, parsed.message].find((item) => {
return typeof item === "string" && item.trim();
});
if (typeof value !== "string") return "";
const message = value.replace(/\s+/g, " ").trim();
return looksLikeHtmlDocument(message) ? "" : message;
} catch {
return "";
}
}
function formatFeedbackSubmitError(status: number, raw: string, isEn: boolean) {
const jsonMessage = extractFeedbackErrorMessage(raw);
if (jsonMessage) return jsonMessage.slice(0, 180);
if (looksLikeHtmlDocument(raw)) {
return isEn
? `Service is temporarily unavailable. Please retry in a moment. (HTTP ${status})`
: `服务暂时不可用,请稍后重试。(HTTP ${status}`;
}
const message = String(raw || "").replace(/\s+/g, " ").trim();
if (message) return `${message.slice(0, 180)} (HTTP ${status})`;
return `HTTP ${status}`;
}
export function UserFeedbackModal({ export function UserFeedbackModal({
draft, draft,
isEn, isEn,
@@ -134,8 +177,8 @@ export function UserFeedbackModal({
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const detail = (await res.text().catch(() => "")).slice(0, 200); const raw = await res.text().catch(() => "");
throw new Error(detail || `HTTP ${res.status}`); throw new Error(formatFeedbackSubmitError(res.status, raw, isEn));
} }
const payload = (await res.json().catch(() => null)) as { const payload = (await res.json().catch(() => null)) as {
feedback?: UserFeedbackEntry; feedback?: UserFeedbackEntry;
@@ -143,7 +186,8 @@ export function UserFeedbackModal({
setSubmitted(true); setSubmitted(true);
onSubmitted?.(payload?.feedback); onSubmitted?.(payload?.feedback);
} catch (err) { } catch (err) {
setError(String(err).slice(0, 220)); const message = err instanceof Error ? err.message : String(err);
setError(message.slice(0, 220));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -57,6 +57,12 @@ export function runTests() {
modalSource.includes("type=\"textarea\"") === false, modalSource.includes("type=\"textarea\"") === false,
"feedback modal must submit to the feedback API, lock contact to the login email when available, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types", "feedback modal must submit to the feedback API, lock contact to the login email when available, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
); );
assert(
modalSource.includes("formatFeedbackSubmitError") &&
modalSource.includes("looksLikeHtmlDocument") &&
!modalSource.includes("throw new Error(detail || `HTTP ${res.status}`)"),
"feedback modal must not expose raw Cloudflare/HTML error pages to users",
);
assert( assert(
feedbackRouteSource.includes("export async function GET") && feedbackRouteSource.includes("export async function GET") &&
feedbackRouteSource.includes("method: \"GET\"") && feedbackRouteSource.includes("method: \"GET\"") &&
@@ -78,6 +78,15 @@ export function runTests() {
assert(nginx.includes("location /api/events"), "Nginx deploy config must route /api/events separately"); assert(nginx.includes("location /api/events"), "Nginx deploy config must route /api/events separately");
assert(nginx.includes("proxy_buffering off"), "Nginx /api/events must disable proxy buffering for SSE"); assert(nginx.includes("proxy_buffering off"), "Nginx /api/events must disable proxy buffering for SSE");
assert(nginx.includes("proxy_read_timeout 86400s"), "Nginx /api/events must keep SSE connections open"); assert(nginx.includes("proxy_read_timeout 86400s"), "Nginx /api/events must keep SSE connections open");
const frontendServerStart = nginx.indexOf("server_name polyweather.top www.polyweather.top");
const apiServerStart = nginx.indexOf("server_name api.polyweather.top");
const frontendServer = nginx.slice(frontendServerStart, apiServerStart);
assert(
frontendServer.includes("location /api/events") &&
frontendServer.includes("proxy_pass http://127.0.0.1:8000") &&
frontendServer.indexOf("location /api/events") < frontendServer.indexOf("location / {"),
"Nginx frontend host must route /api/events directly to FastAPI before the generic Next.js location",
);
const weatherSources = readRepoFile("src", "data_collection", "weather_sources.py"); const weatherSources = readRepoFile("src", "data_collection", "weather_sources.py");
assert(weatherSources.includes("_emit_temperature_patch_if_changed"), "collector must centralize temperature patch emission"); assert(weatherSources.includes("_emit_temperature_patch_if_changed"), "collector must centralize temperature patch emission");