feat(tree): close rust family shell cutover
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user