0.3.8 修复外网访问
This commit is contained in:
+47
-6
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user