0.3.8 修复外网访问

This commit is contained in:
liaibo
2026-01-26 00:59:15 +08:00
parent 06defcf5b6
commit 0f22a3ff0b
4 changed files with 169 additions and 31 deletions
+47 -6
View File
@@ -1,7 +1,9 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { gzipSync } from "node:zlib";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
// 说明:ONLYOFFICE 文档服务器在编辑过程中会从 `/cache/*` 拉取二进制缓存(例如 Editor.bin)。
// 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上,
@@ -53,6 +55,21 @@ const isStrongCache = (cc: string) => {
return v.includes("max-age=604800");
};
const shouldGzip = (request: NextRequest, contentType: string) => {
const ae = String(request.headers.get("accept-encoding") || "").toLowerCase();
if (!ae.includes("gzip")) return false;
const ct = String(contentType || "").toLowerCase();
const compressible =
ct.startsWith("text/") ||
ct.includes("javascript") ||
ct.includes("json") ||
ct.includes("xml") ||
ct.includes("svg");
return compressible;
};
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const incomingUrl = new URL(request.url);
const target = new URL(
@@ -62,7 +79,7 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const headers = new Headers(request.headers);
headers.delete("host");
// 说明:避免上游 gzip 后被自动解压但仍带 content-encoding,导致浏览器二次解压错误
// 说明:让上游返回明文(identity),我们在此处按需 gzip 压缩,保证 content-encoding 与 body 一致。
headers.set("accept-encoding", "identity");
stripHopByHopHeaders(headers);
@@ -80,11 +97,13 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const upstream = await fetch(target, init);
const upstreamContentLength = upstream.headers.get("content-length");
const outHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(outHeaders);
outHeaders.delete("content-encoding");
outHeaders.delete("content-length");
const cacheControl = pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method);
const contentType = upstream.headers.get("content-type") || "";
const cacheControl = pickCacheControl(pathParts, contentType, request.method);
outHeaders.set("cache-control", cacheControl);
outHeaders.delete("pragma");
outHeaders.delete("expires");
@@ -99,10 +118,32 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
}
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: outHeaders,
});
// 说明:同 onlyoffice-server 路由,对强缓存二进制改为定长响应,提升浏览器落盘缓存命中率。
if (isStrongCache(cacheControl)) {
outHeaders.delete("vary");
outHeaders.set("vary", "Accept-Encoding");
if (method === "HEAD") {
if (upstreamContentLength) outHeaders.set("content-length", upstreamContentLength);
return new NextResponse(null, { status: upstream.status, headers: outHeaders });
}
const buf = await upstream.arrayBuffer();
if (shouldGzip(request, contentType)) {
try {
const gz = gzipSync(Buffer.from(buf), { level: 6 });
outHeaders.set("content-encoding", "gzip");
outHeaders.set("content-length", String(gz.byteLength));
return new NextResponse(gz, { status: upstream.status, headers: outHeaders });
} catch {
// ignore
}
}
outHeaders.set("content-length", String(buf.byteLength));
return new NextResponse(buf, { status: upstream.status, headers: outHeaders });
}
return new NextResponse(upstream.body, { status: upstream.status, headers: outHeaders });
};
type RouteCtx = { params: Promise<{ path: string[] }> };
@@ -1,18 +1,23 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { gzipSync } from "node:zlib";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const DISABLE_SERVICE_WORKER_SNIPPET = `
const SERVICE_WORKER_SAFE_PATCH_SNIPPET = `
<script>
// 说明:在部分隧道/证书环境下,浏览器会阻止注册 ServiceWorker(报 SecurityError/SSL 证书错误)。
// ONLYOFFICE 默认会尝试注册 document_editor_service_worker.js 用于静态资源缓存,失败后会在控制台打印错误,
// 甚至可能触发上层框架的全局错误捕获导致白屏。
// 这里在编辑器 HTML 里提前兜底,把 register 改成“静默成功”,不影响编辑器核心功能
window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
// 说明:
// - ONLYOFFICE 默认会尝试注册 document_editor_service_worker.js 用于静态资源缓存(这对二次打开速度很关键)。
// - 但在部分隧道/证书环境下,浏览器会阻止 ServiceWorker(例如证书不被信任导致 isSecureContext=false),
// 失败后会在控制台反复报错,甚至可能触发上层框架的全局错误捕获导致白屏
// 这里做“安全兜底补丁”:
// - 若 ServiceWorker 可正常注册:不拦截(保留 ONLYOFFICE 自带缓存能力)
// - 若注册失败:吞掉错误并返回一个 fake registration,避免反复报错/白屏
window.__MNOTE_ONLYOFFICE_SW_PATCH__ = true;
(function () {
try {
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) return;
@@ -22,11 +27,21 @@ window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
unregister: function () { return Promise.resolve(true); }
};
var sw = navigator.serviceWorker;
var noopRegister = function () { return Promise.resolve(fakeReg); };
try { sw.register = noopRegister; } catch (e) {}
var origRegister = null;
try { origRegister = sw.register && sw.register.bind(sw); } catch (e) {}
var safeRegister = function () {
try {
if (!origRegister) return Promise.resolve(fakeReg);
var p = origRegister.apply(sw, arguments);
return Promise.resolve(p).catch(function () { return fakeReg; });
} catch (e) {
return Promise.resolve(fakeReg);
}
};
try { sw.register = safeRegister; } catch (e) {}
try {
var proto = Object.getPrototypeOf(sw);
if (proto && proto.register) proto.register = noopRegister;
if (proto && proto.register) proto.register = safeRegister;
} catch (e) {}
} catch (e) {}
})();
@@ -132,18 +147,30 @@ const stripHopByHopHeaders = (headers: Headers) => {
}
};
const injectDisableServiceWorker = (html: string) => {
const injectHtmlFixups = (html: string) => {
// 说明:某些网络/DNS 环境下无法解析 `static.cloudflareinsights.com`
// ONLYOFFICE 页面若被注入了 Cloudflare 的 beacon 脚本,会在控制台反复报错,
// 并可能因为 DNS 超时而拖慢首次加载。这里在反代层面做一次轻量清理。
const cleaned = html
// 说明:移除 Cloudflare Insights/Web Analytics beacon
.replace(
/<script[^>]*\bsrc=(["'])https?:\/\/static\.cloudflareinsights\.com\/beacon\.min\.js[^>]*>\s*<\/script>/gi,
"",
)
// 说明:部分场景会有 `cdn-cgi` 相关注入(如 rocket-loader),同样会指向 Cloudflare 域名
.replace(/<script[^>]*\bsrc=(["'])\/cdn-cgi\/[^>]*>\s*<\/script>/gi, "");
// 说明:只注入一次,避免重复拼接
const hasSw = html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__");
const hasXhr = html.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__");
if (hasSw && hasXhr) return html;
const hasSw = cleaned.includes("window.__MNOTE_ONLYOFFICE_SW_PATCH__");
const hasXhr = cleaned.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__");
if (hasSw && hasXhr) return cleaned;
const injected = [
hasSw ? "" : DISABLE_SERVICE_WORKER_SNIPPET,
hasSw ? "" : SERVICE_WORKER_SAFE_PATCH_SNIPPET,
hasXhr ? "" : XHR_REWRITE_SNIPPET,
]
.filter(Boolean)
.join("\n");
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${injected}\n`);
return cleaned.replace(/<head[^>]*>/i, (m) => `${m}\n${injected}\n`);
};
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
@@ -165,9 +192,17 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri
return "no-store";
}
// 说明:/cache 下是 ONLYOFFICE 运行期二进制缓存(例如 Editor.bin、字体包、拼写词典等),
// 通常以内容 ID 命名(可能没有扩展名),但内容在同一路径下应保持稳定。
// 对这类资源做较长缓存可以显著提升二次打开速度。
if (lower.includes("/cache/")) {
return "public, max-age=604800, stale-while-revalidate=86400";
}
const isStaticByPath =
lower.includes("/web-apps/") ||
lower.includes("/sdkjs/") ||
lower.includes("/fonts/") ||
lower.endsWith(".js") ||
lower.endsWith(".css") ||
lower.endsWith(".map") ||
@@ -187,7 +222,7 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri
// 说明:ONLYOFFICE 静态资源体积大,且文件名通常稳定;这里尽量给浏览器缓存,提升二次打开速度。
if (isStaticByPath) {
return "public, max-age=604800, stale-while-revalidate=86400";
return "public, max-age=604800, stale-while-revalidate=86400, immutable";
}
return "public, max-age=300, stale-while-revalidate=300";
@@ -208,6 +243,23 @@ const isStaticCacheControl = (cc: string) => {
return v.includes("max-age=604800");
};
const shouldGzip = (request: NextRequest, contentType: string) => {
// 说明:在 TCP 隧道/跨地域场景下,静态资源体积大时开启 gzip 能显著降低首开耗时。
// 仅对典型文本资源启用,避免对 woff2/wasm/bin 等已压缩或不适合压缩的内容浪费 CPU。
const ae = String(request.headers.get("accept-encoding") || "").toLowerCase();
if (!ae.includes("gzip")) return false;
const ct = String(contentType || "").toLowerCase();
const compressible =
ct.startsWith("text/") ||
ct.includes("javascript") ||
ct.includes("json") ||
ct.includes("xml") ||
ct.includes("svg");
return compressible;
};
const proxy = async (request: NextRequest, pathParts: string[]) => {
const incomingUrl = new URL(request.url);
const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`);
@@ -271,8 +323,8 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
headers.set("x-forwarded-proto", proto);
headers.set("x-forwarded-port", port);
headers.set("x-forwarded-prefix", "/onlyoffice-server");
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
// 说明:上游返回明文(identity),我们在此处按需 gzip 压缩,保证 content-encoding 与 body 一致。
// 避免 Node fetch 自动解压后仍携带 content-encoding导致浏览器报 ERR_CONTENT_DECODING_FAILED。
headers.set("accept-encoding", "identity");
stripHopByHopHeaders(headers);
@@ -291,6 +343,7 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
};
const upstream = await fetch(target, init);
const upstreamContentLength = upstream.headers.get("content-length");
const outHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(outHeaders);
outHeaders.delete("content-encoding");
@@ -314,9 +367,39 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
}
}
// 说明:在部分隧道/反代场景下,浏览器对 “chunked” 的大静态资源不会写入磁盘缓存,
// 导致重复打开仍然完整下载(几十 MB)。这里对“强缓存静态资源”改为定长响应:
// - 先把上游 body 缓冲到 ArrayBuffer
// - 写入 content-length
// 这样浏览器更容易落盘缓存,二次打开会明显提速。
if (isStaticCacheControl(cacheControl) && !contentType.includes("text/html")) {
outHeaders.delete("vary");
outHeaders.set("vary", "Accept-Encoding");
if (method === "HEAD") {
if (upstreamContentLength) outHeaders.set("content-length", upstreamContentLength);
return new NextResponse(null, { status: upstream.status, headers: outHeaders });
}
const buf = await upstream.arrayBuffer();
// 说明:优先 gzip(如果客户端支持),可显著降低跨地域/隧道场景下首开耗时。
if (shouldGzip(request, contentType)) {
try {
const gz = gzipSync(Buffer.from(buf), { level: 6 });
outHeaders.set("content-encoding", "gzip");
outHeaders.set("content-length", String(gz.byteLength));
return new NextResponse(gz, { status: upstream.status, headers: outHeaders });
} catch {
// ignore,回退到未压缩
}
}
outHeaders.set("content-length", String(buf.byteLength));
return new NextResponse(buf, { status: upstream.status, headers: outHeaders });
}
if (contentType.includes("text/html")) {
const html = await upstream.text();
const injected = injectDisableServiceWorker(html);
const injected = injectHtmlFixups(html);
return new NextResponse(injected, {
status: upstream.status,
headers: outHeaders,
@@ -27,13 +27,21 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
}
const browserProtocol = window.location.protocol;
const browserHost = window.location.hostname;
const shouldUseSameOriginProxy = () => {
// 说明:
// - 本项目的 Convex 自托管通常运行在本机 3210 端口;
// - 通过 FRP/域名访问前端时,通常不会额外暴露 3210;
// 因此只要不是 localhost/127.0.0.1 访问,就优先走同源 `/convex/*` 反代(由 dev/prod-server.js 处理),
// 避免登录后因 Convex 连接失败而卡在“无法进入/未同步登录态”的状态。
return !isLocalHost(browserHost);
};
// 浏览器侧:优先尊重 NEXT_PUBLIC_CONVEX_URL(例如生产环境的 https://xxx.convex.cloud)。
// 仅在 envUrl 是 localhost/127.0.0.1 时,才把 host 改为当前 hostname,避免通过域名访问前端时出现不一致。
if (envUrlRaw) {
try {
const url = new URL(envUrlRaw);
const browserHost = window.location.hostname;
// 当通过 HTTPS(如 frp/nginx)访问前端时,浏览器不允许从 https 页面发起 ws://(不安全)连接。
// 若 envUrl 仍是 http://localhost:3210,则走同源反代(由 scripts/dev-server.js 处理 Upgrade)转到本机 3210。
@@ -41,9 +49,10 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
return normalize(`${window.location.origin}${convexProxyPath}`);
}
if (isLocalHost(url.hostname) && !isLocalHost(browserHost)) {
// 非 https 访问时才尝试“借用当前 host:3210”;https 场景已提前走同源反代
url.hostname = browserHost;
if (isLocalHost(url.hostname) && shouldUseSameOriginProxy()) {
// 说明:HTTP 场景下如果直接改成 `${browserHost}:3210`,通常会因为 3210 未暴露而连接失败
// 因此对“域名访问”同样走同源反代。
return normalize(`${window.location.origin}${convexProxyPath}`);
}
return normalize(url.toString());
@@ -54,8 +63,9 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
}
// 未配置 env:使用当前主机名,端口固定 3210,并跟随当前页面协议(http/https)。
// https 场景下浏览器禁止 ws://,因此默认走同源反代 `/convex`(需要 server 支持 Upgrade 透传)。
if (browserProtocol === "https:") {
// - https 场景下浏览器禁止 ws://,因此默认走同源反代 `/convex`(需要 server 支持 Upgrade 透传)。
// - 域名/FRP 场景下通常也不会暴露 3210,因此同样走同源反代。
if (browserProtocol === "https:" || shouldUseSameOriginProxy()) {
return normalize(`${window.location.origin}${convexProxyPath}`);
}
+4
View File
@@ -14,6 +14,10 @@ const isPublicRoute = createRouteMatcher([
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
"/api/onlyoffice/proxy(.*)",
"/api/onlyoffice/callback(.*)",
// 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。
// 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。
"/onlyoffice-server(.*)",
"/cache(.*)",
"/api/health(.*)",
"/_next(.*)",
"/favicon.ico",