497 lines
16 KiB
JavaScript
497 lines
16 KiB
JavaScript
#!/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:可显式指定;未指定或失效时优先探测 8082,再回退 8081
|
||
*/
|
||
|
||
const http = require("http");
|
||
const net = require("net");
|
||
const path = require("path");
|
||
|
||
// 说明:Next 当前 vendored 的 browserslist/baseline 提示会在进程启动早期输出,
|
||
// 即使项目侧依赖已经升级也未必消失。这里默认静音这类“当前不可操作”的噪音;
|
||
// 如需排查真实数据新鲜度,可在启动前显式传入 `false` 恢复原始警告。
|
||
if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === "false") {
|
||
delete process.env.BROWSERSLIST_IGNORE_OLD_DATA;
|
||
} else if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === undefined) {
|
||
process.env.BROWSERSLIST_IGNORE_OLD_DATA = "true";
|
||
}
|
||
if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === "false") {
|
||
delete process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA;
|
||
} else if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === undefined) {
|
||
process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA = "true";
|
||
}
|
||
|
||
const next = require("next");
|
||
const { parse: parseUrl } = require("url");
|
||
const { buildTreeShellRuntime } = require("./build-tree-shell-runtime");
|
||
const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island");
|
||
|
||
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||
const CONVEX_PREFIX = "/convex";
|
||
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
|
||
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:8081");
|
||
push("http://localhost:8082");
|
||
push("http://localhost:8081");
|
||
|
||
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 {}
|
||
});
|
||
|
||
const method = String(req.method || "GET").toUpperCase();
|
||
const hasBody =
|
||
!["GET", "HEAD"].includes(method) &&
|
||
((typeof req.headers["content-length"] === "string" && req.headers["content-length"] !== "0") ||
|
||
String(req.headers["transfer-encoding"] || "").trim().length > 0);
|
||
|
||
if (!hasBody) {
|
||
upstreamReq.end();
|
||
return;
|
||
}
|
||
|
||
req.pipe(upstreamReq);
|
||
}
|
||
|
||
async function main() {
|
||
const port = resolvePort();
|
||
const hostname = resolveHostname();
|
||
const dev = true;
|
||
|
||
// 说明:tree shell iframe 通过 3000 同源 js glue + wasm 直接调用 reducer,因此 dev 启动前先生成正式 artifact。
|
||
await buildTreeShellRuntime();
|
||
|
||
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
|
||
await buildLeptosTiptapIsland();
|
||
|
||
const app = next({ dev, dir: path.join(__dirname, ".."), hostname, port });
|
||
const handle = app.getRequestHandler();
|
||
|
||
await app.prepare();
|
||
|
||
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;
|
||
}
|
||
// 说明:Next custom server 会在首个 HTTP 请求时自动给同一个 http.Server 绑定 HMR upgrade listener。
|
||
// 非 Convex / ONLYOFFICE 的 Upgrade 交给 Next 自己的 listener,避免同一 socket 被处理两次。
|
||
});
|
||
|
||
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);
|
||
});
|