Files
mnote/wolai-frontend/src/lib/runtime-config.ts
T
lix-2026 8353aea2f9 feat(editor): save leptos island and page aggregate alignment progress
- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
2026-04-22 05:57:06 +08:00

329 lines
12 KiB
TypeScript
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.
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<T>(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<MnoteRuntimeConfig> => {
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<typeof import("fs")>("node:fs");
const path = getServerNodeBuiltin<typeof import("path")>("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<MnoteRuntimeConfig>;
} 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);
}