feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -1233,7 +1233,7 @@ describe("/api/tree/commands route", () => {
);
});
it("embed action 走 tree.node.embed,并生成 pageReference 保存 payload", async () => {
it("embed action 走 tree.node.embed,并 pageReference 组装交给 Rust Page Aggregate preflight", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async (name: string, args: { id: string }) => {
@@ -1342,18 +1342,19 @@ describe("/api/tree/commands route", () => {
targetDocumentId: "doc_target",
revision: 7,
conflictDetectionKey: "doc_target:7",
content: [
{ id: "anchor_1", type: "paragraph" },
{
id: expect.any(String),
type: "pageReference",
props: {
pageId: "doc_source",
title: "来源页面",
},
},
],
}),
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: "doc_source",
sourceTitle: "来源页面",
targetDocumentId: "doc_target",
targetContent: [
{ id: "anchor_1", type: "paragraph" },
],
anchorBlockId: "anchor_1",
blockId: expect.any(String),
},
},
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
@@ -1,10 +1,8 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
assertDocumentId,
assertTitle,
@@ -19,7 +17,6 @@ import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
@@ -591,32 +588,9 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull(
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
);
const anchorIndex = anchorId
? currentBlocks.findIndex(
(block) =>
typeof block === "object" &&
block !== null &&
String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId =
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
@@ -629,24 +603,30 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
workspaceId,
commandName: "tree.node.embed",
payload: {
...buildDocumentSavePayload({
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: nextContent,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
sourceDocumentId: sourceId,
targetDocumentId: targetId,
anchorBlockId: anchorId,
},
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: sourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: targetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: randomUUID(),
},
},
pageId: targetId,
client,
});
@@ -98,6 +98,23 @@ describe("/api/tree/projections/file route", () => {
},
],
edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 2,
visibleEdges: 0,
},
},
},
},
});
mockDocumentBridgeErrorResponse.mockClear();
});
@@ -130,6 +147,15 @@ describe("/api/tree/projections/file route", () => {
result: {
projection: "file_tree",
rootNodeId: "page_root",
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
},
},
},
},
});
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/runtime/reduce route", () => {
beforeEach(() => {
vi.resetModules();
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源 POST 代理到 mnote-web runtime reduce,并原样转发 JSON body 与来源 header", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true, revision: 7 }), {
status: 200,
statusText: "OK",
headers: {
"content-type": "application/json; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"transfer-encoding": "chunked",
"x-upstream": "mnote-web-runtime",
},
}),
);
const { POST } = await import("./route");
const body = JSON.stringify({
workspaceId: "ws_body",
op: "tree.node.rename",
payload: { documentId: "doc_1", title: "新标题" },
});
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce?workspaceId=ws_query", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: "session=abc",
},
body,
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce?workspaceId=ws_query",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body,
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("content-type")).toBe("application/json");
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_runtime_reduce_proxy");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("wolai-frontend");
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_query");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("transfer-encoding")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-runtime");
expect(await response.json()).toEqual({ ok: true, revision: 7 });
});
it("没有 workspaceId query 时使用 x-mnote-workspace-id header fallback", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
"x-mnote-workspace-id": "ws_header",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: {
"content-type": "application/json",
},
}),
);
const { POST } = await import("./route");
await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-workspace-id": "ws_header",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce",
expect.objectContaining({
method: "POST",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_header");
});
it("上游调用异常时返回 502 JSON", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(new Headers());
mockFetch.mockRejectedValue(new Error("runtime reduce unavailable"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
error: "runtime reduce unavailable",
});
});
});
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function POST(request: Request) {
try {
const requestUrl = new URL(request.url);
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/api/tree/runtime/reduce", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("content-type", "application/json");
headers.set("accept", "application/json");
headers.set("x-mnote-source-channel", "next_tree_runtime_reduce_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "POST",
headers,
body: await request.text(),
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree runtime reduce 代理失败";
return NextResponse.json(
{
error: message,
},
{
status: 502,
headers: {
"cache-control": "no-store",
},
},
);
}
}