Files
mnote/wolai-frontend/scripts/prod-server.js
T
lix-2026 b33ffb99e7 feat: 收口文档桥接与 OnlyOffice/Sidebar 回归
- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
2026-04-15 03:06:29 +08:00

436 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 自定义 Next 生产 server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - 解决 Convex 在 HTTPSfrp/nginx)场景下浏览器不能连接 ws:// 的问题:通过同源 `/convex/*` 反代到本机 Convex。
*
* 用法:
* - pnpm build
* - pnpm start (默认会执行本脚本)
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000;
let cachedOnlyOfficeInternalUrl = "";
let cachedOnlyOfficeInternalUrlAt = 0;
let pendingOnlyOfficeInternalUrl = null;
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
if (idx === -1) return null;
const v = process.argv[idx + 1];
if (!v || v.startsWith("-")) return null;
return v;
}
function resolvePort() {
const fromArg = readArgValue("-p") || readArgValue("--port");
const raw = fromArg || process.env.PORT || "3000";
const n = Number(raw);
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
}
function resolveHostname() {
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
}
function isOnlyOfficePath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
} catch {
return false;
}
}
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function normalizeOnlyOfficeInternalUrl(raw) {
const value = String(raw || "").trim().replace(/\/+$/, "");
if (!value) return "";
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
function listOnlyOfficeInternalUrlCandidates() {
const candidates = [];
const push = (value) => {
const normalized = normalizeOnlyOfficeInternalUrl(value);
if (!normalized) return;
if (!candidates.includes(normalized)) candidates.push(normalized);
};
push(process.env.ONLYOFFICE_INTERNAL_URL);
for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) {
push(raw);
}
push(DEFAULT_ONLYOFFICE_INTERNAL_URL);
push("http://127.0.0.1:8082");
push("http://localhost:8081");
push("http://localhost:8082");
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
}
async function probeOnlyOfficeInternalUrl(candidate) {
if (typeof fetch !== "function") return false;
try {
const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
const response = await fetch(probeUrl, {
method: "HEAD",
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(2500),
});
return response.ok;
} catch {
return false;
}
}
async function resolveOnlyOfficeInternalUrl() {
const now = Date.now();
if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < ONLYOFFICE_RESOLVE_CACHE_TTL_MS) {
return new URL(`${cachedOnlyOfficeInternalUrl}/`);
}
if (!pendingOnlyOfficeInternalUrl) {
pendingOnlyOfficeInternalUrl = (async () => {
const candidates = listOnlyOfficeInternalUrlCandidates();
for (const candidate of candidates) {
if (await probeOnlyOfficeInternalUrl(candidate)) {
return candidate;
}
}
return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL;
})();
}
try {
const resolved = await pendingOnlyOfficeInternalUrl;
cachedOnlyOfficeInternalUrl = resolved;
cachedOnlyOfficeInternalUrlAt = Date.now();
return new URL(`${resolved}/`);
} finally {
pendingOnlyOfficeInternalUrl = null;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
const lines = [];
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
if (key.toLowerCase() === "host") continue;
if (Array.isArray(v)) {
lines.push(`${key}: ${v.join(", ")}`);
} else {
lines.push(`${key}: ${String(v)}`);
}
}
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
lines.push("");
return lines.join("\r\n");
}
async function proxyOnlyOfficeUpgrade(req, socket, head) {
let target;
try {
target = await resolveOnlyOfficeInternalUrl();
} catch (err) {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][onlyoffice-ws] resolve error", msg);
} catch {}
try {
socket.destroy();
} catch {}
return;
}
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][onlyoffice-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
console.log("[prod-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
const dev = false;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-prod-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => {
resolveOnlyOfficeInternalUrl()
.then((onlyofficeTarget) => {
console.log(
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${onlyofficeTarget.toString().replace(/\/$/, "")}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
})
.catch(() => {
console.log(
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || DEFAULT_ONLYOFFICE_INTERNAL_URL}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
});
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});