export type MnoteRuntimeConfig = { /** * 是否启用 Convex(自部署)链路。 * 说明:该字段由服务端在运行期注入到 window.__MNOTE_RUNTIME_CONFIG__,用于客户端按需关闭 Supabase 相关能力。 */ useConvex?: boolean; supabaseUrl?: string; /** * 服务端/本机回源用的 Supabase 地址(通常是 HTTP),用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。 * 说明:该字段会出现在 public/mnote-env.json 中,但它主要给服务端读取使用。 */ supabaseInternalUrl?: string; supabaseAnonKey?: string; backendUrl?: string; onlyofficeBaseUrl?: string; onlyofficeBaseUrlWeb?: string; onlyofficeBaseUrlDesktop?: string; onlyofficeStorageHostOverride?: string; onlyofficeStorageHostOverrideWeb?: string; onlyofficeStorageHostOverrideDesktop?: string; cloudflareAppOrigin?: string; onlyofficeProxyOriginWeb?: string; onlyofficeCallbackOriginWeb?: string; onlyofficeCallbackOriginDesktop?: string; /** * Rust Web 主入口,仅用于客户端渐进增强能力(例如独立 tree shell)。 * 说明:禁止把它作为主页面 SSR 首屏依赖。 */ mnoteWebBaseUrl?: string; /** * 是否启用 Rust Web tree shell 客户端增强。 * 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。 */ mnoteWebTreeShellEnabled?: boolean; /** * 编辑器 host 选择。 * 说明:默认主链为 leptos_tiptap_island;可通过运行时配置显式切换。 */ documentEditorHost?: | "blocknote" | "leptos_tiptap_island" | "leptos_tiptap_iframe_debug"; /** * 编辑器 BlockNote 回退总开关(kill switch)。 * 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。 */ documentEditorBlocknoteKillSwitch?: boolean; /** * 是否为桌面端(Electron)运行。 */ isDesktop?: boolean; /** * ONLYOFFICE 文档服务器拉取 document.url 时,如需使用 /api/onlyoffice/proxy * 规避 signedUrl 的 token 参数冲突,可把代理地址指向一个“文档服务器可访问”的公网 Origin。 * * - 桌面端本地运行(window.location.origin):通常可不配置 * - 通过 Cloudflare Tunnel 使用远端 ONLYOFFICE:建议配置为 https://app.<你的域名> */ onlyofficeProxyOrigin?: string; onlyofficeCallbackOrigin?: string; }; declare global { interface Window { __MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig; } } const parseRuntimeBoolean = (value: unknown): boolean | undefined => { if (typeof value === "boolean") { return value; } if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); if (!normalized) { return undefined; } if (["1", "true", "yes", "on"].includes(normalized)) { return true; } if (["0", "false", "no", "off"].includes(normalized)) { return false; } return undefined; }; const parseDocumentEditorHost = ( value: unknown, ): MnoteRuntimeConfig["documentEditorHost"] | undefined => { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); if (!normalized) { return undefined; } if (normalized === "blocknote") { return "blocknote"; } if ( normalized === "leptos_tiptap_island" || normalized === "leptos_tiptap_runtime" || normalized === "leptos_tiptap_inline" || normalized === "leptos_tiptap" ) { return "leptos_tiptap_island"; } if ( normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap_debug" || normalized === "iframe_debug" ) { return "leptos_tiptap_iframe_debug"; } return undefined; }; function getServerNodeBuiltin(moduleName: string): T | null { if (typeof window !== "undefined") { return null; } const processWithBuiltin = process as NodeJS.Process & { getBuiltinModule?: (id: string) => unknown; }; if (typeof processWithBuiltin.getBuiltinModule === "function") { const builtIn = processWithBuiltin.getBuiltinModule(moduleName); if (builtIn) { return builtIn as T; } } try { // 说明:某些 Node/打包环境仍保留 CommonJS require,这里只作为兜底。 const runtimeRequire = new Function( 'return typeof require === "function" ? require : null', )() as NodeJS.Require | null; if (runtimeRequire) { return runtimeRequire(moduleName) as T; } } catch { return null; } return null; } const readFromEnv = (): MnoteRuntimeConfig => ({ useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1", supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL, supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL, ...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined ? { mnoteWebBaseUrl: process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL, } : {}), ...(parseRuntimeBoolean( process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ?? process.env.MNOTE_WEB_TREE_SHELL_ENABLED, ) !== undefined ? { mnoteWebTreeShellEnabled: parseRuntimeBoolean( process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ?? process.env.MNOTE_WEB_TREE_SHELL_ENABLED, ), } : {}), ...(parseRuntimeBoolean( process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ?? process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH, ) !== undefined ? { documentEditorBlocknoteKillSwitch: parseRuntimeBoolean( process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ?? process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH, ), } : {}), ...(parseDocumentEditorHost( process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ?? process.env.DOCUMENT_EDITOR_HOST, ) !== undefined ? { documentEditorHost: parseDocumentEditorHost( process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ?? process.env.DOCUMENT_EDITOR_HOST, ), } : {}), onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL, onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE, onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN, onlyofficeCallbackOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_CALLBACK_ORIGIN, cloudflareAppOrigin: process.env.NEXT_PUBLIC_CLOUDFLARE_APP_ORIGIN, }); const readFromPublicJson = (): Partial => { if (typeof window !== "undefined") return {}; try { // 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。 // 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。 // 网页端运行时 process.cwd() 通常为 wolai-frontend。 // 说明:这里不能直接静态引入 `node:fs` / `node:path`, // 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。 // Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require。 const fs = getServerNodeBuiltin("node:fs"); const path = getServerNodeBuiltin("node:path"); if (!fs || !path) { return {}; } // 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`, // 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。 // 这里向上查找多级目录,确保能读取到真正的 public/mnote-env.json。 const candidates: string[] = []; let dir = process.cwd(); for (let i = 0; i < 6; i += 1) { candidates.push(path.join(dir, "public", "mnote-env.json")); const next = path.dirname(dir); if (next === dir) break; dir = next; } const existing = candidates.filter((p) => fs.existsSync(p)); if (existing.length === 0) return {}; // 说明:standalone 产物可能包含 `.next/standalone/public/mnote-env.json`,但它通常是构建时拷贝, // 用户更希望修改“项目目录下”的 public/mnote-env.json 即可生效。 // 因此这里优先选择不在 `.next` 目录下的配置文件。 const isInNextDir = (p: string) => p.split(path.sep).includes(".next"); const filePath = existing.find((p) => !isInNextDir(p)) ?? existing[0]; const raw = fs.readFileSync(filePath, { encoding: "utf8" }); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") return {}; return parsed as Partial; } catch { return {}; } }; const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig => { const isDesktop = cfg.isDesktop ?? (typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false); const onlyofficeBaseUrl = isDesktop ? cfg.onlyofficeBaseUrlDesktop || cfg.onlyofficeBaseUrl || cfg.onlyofficeBaseUrlWeb || "" : cfg.onlyofficeBaseUrlWeb || cfg.onlyofficeBaseUrl || cfg.onlyofficeBaseUrlDesktop || ""; const onlyofficeStorageHostOverride = isDesktop ? (cfg.onlyofficeStorageHostOverrideDesktop ?? cfg.onlyofficeStorageHostOverride ?? cfg.onlyofficeStorageHostOverrideWeb ?? "") : (cfg.onlyofficeStorageHostOverrideWeb ?? cfg.onlyofficeStorageHostOverride ?? cfg.onlyofficeStorageHostOverrideDesktop ?? ""); const onlyofficeProxyOrigin = isDesktop ? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeProxyOriginWeb) : (cfg.onlyofficeProxyOriginWeb ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeCallbackOriginDesktop); const onlyofficeCallbackOrigin = isDesktop ? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb) : (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop); const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, ""); const mnoteWebTreeShellEnabled = parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false; const documentEditorHost = parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island"; const documentEditorBlocknoteKillSwitch = parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false; return { ...cfg, isDesktop, mnoteWebBaseUrl, mnoteWebTreeShellEnabled, documentEditorHost, documentEditorBlocknoteKillSwitch, onlyofficeBaseUrl, onlyofficeStorageHostOverride, onlyofficeProxyOrigin, onlyofficeCallbackOrigin, }; }; export function getMnoteRuntimeConfig(): MnoteRuntimeConfig { if (typeof window !== "undefined") { return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv()); } const isDesktop = process.env.MNOTE_DESKTOP === "1"; const publicRuntime = readFromPublicJson(); const envRuntime = readFromEnv(); // 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。 // Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被 // public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。 const merged: MnoteRuntimeConfig = isDesktop ? { ...envRuntime, ...publicRuntime, isDesktop, } : { ...publicRuntime, ...envRuntime, // 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json // 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。 mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled, }; return normalizeRuntimeConfig(merged); }