#!/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); });