- 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
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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);
|
||
}
|