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,
|
||||
|
||||
Reference in New Issue
Block a user