Files
mnote/wolai-frontend/scripts/dev-server.js
T

497 lines
16 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:可显式指定;未指定或失效时优先探测 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);
});