Files
mnote/wolai-frontend/scripts/dev-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

472 lines
15 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 dev server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。
*
* 用法(保持与 next dev 类似):
* - pnpm dev -p 3000
* - node scripts/dev-server.js -p 3000
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082
*/
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);
};
// 说明:ONLYOFFICE 在反向代理下会依赖 X-Forwarded-* 推导“对外地址”,用于拼出 ws/wss 与缓存资源 URL。
// 这里尽量沿用上游(frp/nginx)注入的 x-forwarded-proto/host;若缺失,则回退到本机 http。
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)}`);
}
}
// 说明:补齐/覆盖 forward 信息,避免 ONLYOFFICE 返回指向内部端口的绝对 URL。
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
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("[dev-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 (e) {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[dev-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() {
// 说明:Convex 本地 dev server 默认 3210;对外访问(如 frp https)时,浏览器需要 wss
// 因此这里通过同源反代把 Upgrade 转发到本机 3210。
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("[dev-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;
// 说明:让上游能感知对外协议/域名(主要用于调试;Convex 本身通常不依赖这些头)。
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("[dev-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 = true;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
// 说明:Next dev 的 HMR 依赖 WebSocket/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-dev-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 || "/")) {
try {
console.log(
"[dev-server][convex-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
try {
console.log(
"[dev-server][onlyoffice-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
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(
`[dev-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(
`[dev-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);
});