feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockConvexAuthNextjsToken = vi.fn();
vi.mock("@convex-dev/auth/nextjs/server", () => ({
convexAuthNextjsToken: mockConvexAuthNextjsToken,
}));
describe("/api/auth/mnote-web-token compat route", () => {
beforeEach(() => {
mockConvexAuthNextjsToken.mockReset();
});
it("有 token 时仅作为 next-auth-token-debug compat 边界写入 mnote-web cookie", async () => {
mockConvexAuthNextjsToken.mockResolvedValue("token-demo");
const { GET } = await import("./route");
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("next-auth-token-debug");
expect(payload).toEqual({
ok: true,
owner: "mnote-web",
compatibility: "next-auth-token-debug",
});
expect(response.headers.get("set-cookie")).toContain("mnote_web_convex_token=token-demo");
});
it("无 token 时清空兼容 cookie 并保留 mnote-web owner header", async () => {
mockConvexAuthNextjsToken.mockResolvedValue("");
const { GET } = await import("./route");
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(401);
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("next-auth-token-debug");
expect(payload).toEqual({ error: "未登录" });
expect(response.headers.get("set-cookie")).toContain("mnote_web_convex_token=");
expect(response.headers.get("set-cookie")).toContain("Max-Age=0");
});
});
@@ -2,6 +2,11 @@ import { NextResponse } from "next/server";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
const COOKIE_NAME = "mnote_web_convex_token";
const COMPAT_HEADERS = {
"cache-control": "no-store",
"x-mnote-web-owner": "mnote-web",
"x-mnote-compat-boundary": "next-auth-token-debug",
};
export const dynamic = "force-dynamic";
@@ -18,11 +23,17 @@ export async function GET() {
path: "/",
maxAge: 0,
});
response.headers.set("cache-control", "no-store");
for (const [name, value] of Object.entries(COMPAT_HEADERS)) {
response.headers.set(name, value);
}
return response;
}
const response = NextResponse.json({ ok: true });
const response = NextResponse.json({
ok: true,
owner: "mnote-web",
compatibility: "next-auth-token-debug",
});
response.cookies.set({
name: COOKIE_NAME,
value: token,
@@ -30,6 +41,8 @@ export async function GET() {
sameSite: "lax",
path: "/",
});
response.headers.set("cache-control", "no-store");
for (const [name, value] of Object.entries(COMPAT_HEADERS)) {
response.headers.set(name, value);
}
return response;
}
@@ -1,94 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type CopyTreeItem = {
documentId: string;
recursive: boolean;
};
type CopyTreePayload = {
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
type TreeCopyResponse = {
requestId?: string;
traceId?: string;
result?: {
items?: Array<{
oldId: string;
newId: string;
}>;
} | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: item.documentId.trim(),
recursive: Boolean(item.recursive),
}))
.filter((item) => item.documentId.length > 0);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "copy",
targetParentId:
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
items: normalizedItems,
}),
});
const result = (await response.json().catch(() => null)) as
| TreeCopyResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
result && typeof result === "object" && "error" in result && typeof result.error === "string"
? result.error
: "复制页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
items: result?.result?.items ?? [],
meta: {
requestId: result?.requestId,
traceId: result?.traceId,
commandName: "tree.subtree.copy",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,83 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeCreateResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
execution?: {
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} | null;
} | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { parentId }: { parentId?: string | null } = await request.json();
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "create",
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeCreateResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "创建页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
id: payload?.result?.documentId ?? "",
title: payload?.result?.title ?? "无标题",
parent_id: payload?.result?.parentId ?? null,
sort_order: payload?.result?.sortOrder ?? null,
workspace_id: payload?.result?.workspaceId ?? undefined,
access_scope: payload?.result?.execution?.access_scope ?? "private",
is_template: payload?.result?.execution?.is_template ?? false,
created_at: payload?.result?.execution?.created_at ?? null,
updated_at:
payload?.result?.execution?.updated_at ??
payload?.result?.execution?.created_at ??
null,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.create",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "创建页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,75 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeArchiveResponse = {
requestId?: string;
traceId?: string;
};
type DeletePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, workspaceId }: DeletePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "archive",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeArchiveResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.archive",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,74 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeEmbedResponse = {
requestId?: string;
traceId?: string;
};
type EmbedPayload = {
sourceId?: string | null;
targetId?: string | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { sourceId, targetId }: EmbedPayload = await request.json();
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
if (!normalizedSourceId || !normalizedTargetId) {
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "embed",
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeEmbedResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "嵌入失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.embed",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeMoveResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
sortOrder?: number | null;
} | null;
};
type MovePayload = {
documentId?: string | null;
parentId?: string | null;
position?: number | null;
workspaceId?: string | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "move",
documentId: normalizedDocumentId,
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
sortOrder: typeof position === "number" && Number.isFinite(position) ? Math.floor(position) : 0,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeMoveResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "移动失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.subtree.move",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "移动失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,77 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreePurgeResponse = {
requestId?: string;
traceId?: string;
result?: {
purged?: boolean;
} | null;
};
type PurgePayload = {
documentId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId }: PurgePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "purge",
documentId: normalizedDocumentId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreePurgeResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "彻底删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
purged: payload?.result?.purged ?? true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.purge",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,75 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeRestoreResponse = {
requestId?: string;
traceId?: string;
};
type RestorePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, workspaceId }: RestorePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "restore",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeRestoreResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "恢复失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.restore",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -214,7 +214,11 @@ describe("documents route adapters", () => {
const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
headers: { "content-type": "application/json" },
headers: {
"authorization": "Bearer test-token",
"content-type": "application/json",
"cookie": "convex-auth=test-cookie",
},
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
const payload = await response.json() as {
@@ -235,6 +239,15 @@ describe("documents route adapters", () => {
}),
}),
);
const renameCall = fetchMock.mock.calls.find(([, init]) => {
if (!init || typeof init.body !== "string") {
return false;
}
return init.body.includes('"action":"rename"');
});
const forwardedHeaders = renameCall?.[1]?.headers as Headers;
expect(forwardedHeaders.get("authorization")).toBe("Bearer test-token");
expect(forwardedHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
});
@@ -35,11 +35,20 @@ export async function POST(request: Request) {
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const upstreamHeaders = new Headers({
"content-type": "application/json",
});
const authorization = request.headers.get("authorization");
const cookie = request.headers.get("cookie");
if (authorization) {
upstreamHeaders.set("authorization", authorization);
}
if (cookie) {
upstreamHeaders.set("cookie", cookie);
}
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
headers: upstreamHeaders,
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
@@ -133,6 +133,9 @@ describe("/api/mnote-web/stream route", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-tree-stream-owner")).toBe("rust-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mnote-web-stream-alias");
expect(await response.text()).toContain("event: snapshot");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
@@ -156,7 +156,10 @@ export async function GET(request: Request) {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream",
"x-upstream": "next-tree-stream-compat",
"x-mnote-web-owner": "mnote-web",
"x-mnote-tree-stream-owner": "rust-web",
"x-mnote-compat-boundary": "mnote-web-stream-alias",
},
});
}
@@ -1,96 +0,0 @@
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/shell route", () => {
beforeEach(() => {
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
),
);
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
});
});
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response("<!doctype html><html><body>tree shell</body></html>", {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"x-upstream": "mnote-web-tree",
},
}),
);
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
{
method: "GET",
headers: {
cookie: "session=abc",
},
},
),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
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("accept")).toContain("text/html");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_shell_proxy");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
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("x-upstream")).toBe("mnote-web-tree");
expect(await response.text()).toContain("tree shell");
});
});
@@ -1,80 +0,0 @@
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 GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const debugEnabled =
requestUrl.searchParams.get("debug") === "1" ||
requestUrl.searchParams.get("internal") === "1" ||
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
if (!debugEnabled) {
return NextResponse.json(
{
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
},
{ status: 404 },
);
}
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("accept", "text/html,application/xhtml+xml");
headers.set("x-mnote-source-channel", "next_tree_shell_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: "GET",
headers,
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 shell 代理失败";
return NextResponse.json(
{
error: message,
},
{ status: 502 },
);
}
}
@@ -15,6 +15,12 @@ type MindmapRouteQueryResult = {
meta?: unknown;
};
export const MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "legacy_compat_island",
island: "mindmap_runtime",
} as const;
async function fetchMindmapProjectionOnServer(input: {
docId: string;
mindmapId: string;
@@ -101,10 +107,16 @@ export default async function MindmapFullscreenPage({
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
return (
<MindmapPageClient
docId={docId}
mindmapId={mindmapId}
initialProjection={initialProjection}
/>
<main
data-react-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
data-runtime-role={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.runtimeRole}
data-shell-owner={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.shellOwner}
>
<MindmapPageClient
docId={docId}
mindmapId={mindmapId}
initialProjection={initialProjection}
/>
</main>
);
}
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT,
applyDocWriteToolResultToPageBody,
buildAiAgentSessionsStoragePayload,
extractCurrentPageTitleFromSlashToolResult,
@@ -260,3 +261,16 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
).toBeNull();
});
});
describe("DocumentAiAgentPanel.runtime island contract", () => {
it("AI bridge runtime 固定为 Rust Web/Hermes owned island", () => {
expect(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT).toMatchObject({
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
});
});
});
@@ -27,6 +27,14 @@ type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
export const DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT = {
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
} as const;
const extractCodexMode = (text: string): CodexMode => {
const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b/i);
@@ -863,6 +871,12 @@ export function DocumentAiAgentPanelRuntime({
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
>
<SheetTitle className="sr-only"> AI</SheetTitle>
<div
data-react-island="document_ai_bridge"
data-runtime-role={DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.runtimeRole}
data-bridge-owner={DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.bridgeOwner}
className="h-[calc(100vh-24px)] min-h-0"
>
<AiBridgePanel
title={pageTitle}
subtitle="页面 AI"
@@ -1757,6 +1771,7 @@ export function DocumentAiAgentPanelRuntime({
</DialogContent>
</Dialog>
</AiBridgePanel>
</div>
</SheetContent>
</Sheet>
);
@@ -8,6 +8,13 @@ type SearchPaletteProps = {
workspaceId: string | null;
};
export const SEARCH_PALETTE_ISLAND_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "react_interaction_island",
island: "search_palette",
transport: "/api/search/documents",
} as const;
const SearchPaletteRuntime = dynamic<SearchPaletteProps>(
() => import("./search-palette.runtime").then((mod) => mod.SearchPalette),
{
@@ -46,5 +53,13 @@ export function SearchPaletteHost({ workspaceId }: SearchPaletteProps) {
return null;
}
return <SearchPaletteRuntime workspaceId={workspaceId} />;
return (
<div
data-react-island={SEARCH_PALETTE_ISLAND_CONTRACT.island}
data-runtime-role={SEARCH_PALETTE_ISLAND_CONTRACT.runtimeRole}
data-shell-owner={SEARCH_PALETTE_ISLAND_CONTRACT.shellOwner}
>
<SearchPaletteRuntime workspaceId={workspaceId} />
</div>
);
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { runAiAgent } from "./runAgent";
import { AI_AGENT_LEGACY_RUNTIME_CONTRACT, runAiAgent } from "./runAgent";
import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat";
describe("runAiAgent", () => {
@@ -172,3 +172,14 @@ describe("runAiAgent", () => {
]);
});
});
describe("AI_AGENT_LEGACY_RUNTIME_CONTRACT", () => {
it("legacy runAiAgent 明确降为 Hermes 主桥后的兼容 runtime", () => {
expect(AI_AGENT_LEGACY_RUNTIME_CONTRACT).toEqual({
runtimeRole: "legacy_compat",
mainBridgeOwner: "rust-web-hermes",
legacyEndpoint: "/api/ai-agent/run",
});
});
});
@@ -3,6 +3,12 @@ import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
export const AI_AGENT_LEGACY_RUNTIME_CONTRACT = {
runtimeRole: "legacy_compat",
mainBridgeOwner: "rust-web-hermes",
legacyEndpoint: "/api/ai-agent/run",
} as const;
export type AgentChatFn = (
messages: OpenAiCompatibleChatMessage[],
cfg: OpenAiCompatibleChatOptions,
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { RUST_WEB_MAIN_EXECUTION_BOUNDARY } from "./rust-web-main-execution-boundary";
describe("RUST_WEB_MAIN_EXECUTION_BOUNDARY", () => {
it("固定 3000 公开入口由 mnote-web 拥有,Next 只能作为 legacy compat", () => {
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.publicEntry).toBe("3000");
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.mainWebOwner).toBe("mnote-web");
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.legacyCompatOwner).toBe("next-app-router");
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).not.toContain("main_web_owner");
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.forbiddenNextSemantics).toContain("主 Web gateway owner");
});
it("把主 Web 执行面和允许保留的 React island / legacy bundle 明确分层", () => {
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.rustOwnedPlanes).toEqual(
expect.arrayContaining([
"web_gateway",
"document_shell",
"page_aggregate_transport",
"tree_realtime_stream",
"search_shell",
"ai_bridge_transport",
"mindmap_object_shell",
]),
);
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).toEqual([
"legacy_react_bundle",
"legacy_app_router_compat",
"island_asset_source_until_migrated",
]);
});
});
@@ -0,0 +1,35 @@
export const RUST_WEB_MAIN_EXECUTION_BOUNDARY = {
schema: "mnote.rust_web.main_execution_boundary.v1",
publicEntry: "3000",
mainWebOwner: "mnote-web",
legacyCompatOwner: "next-app-router",
rustOwnedPlanes: [
"web_gateway",
"runtime_config",
"auth_session_handoff",
"document_shell",
"page_aggregate_transport",
"tree_projection_transport",
"tree_command_transport",
"tree_realtime_stream",
"search_shell",
"ai_bridge_transport",
"mindmap_object_shell",
],
nextAllowedDuties: [
"legacy_react_bundle",
"legacy_app_router_compat",
"island_asset_source_until_migrated",
],
forbiddenNextSemantics: [
"主 Web gateway owner",
"页面事实源",
"树排序 canonical plan",
"Page Aggregate command family owner",
"tree realtime event contract owner",
"搜索事实源",
"AI tool protocol owner",
],
} as const;
export type RustWebMainExecutionBoundary = typeof RUST_WEB_MAIN_EXECUTION_BOUNDARY;
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildSearchRequest } from "./request";
import { SEARCH_REQUEST_TRANSPORT_CONTRACT, buildSearchRequest } from "./request";
describe("buildSearchRequest", () => {
it("combines标题过滤与时间范围", () => {
@@ -42,4 +42,44 @@ describe("buildSearchRequest", () => {
expect(payload?.filters.onlyCurrentPage).toBe(false);
expect(payload?.documentId).toBeUndefined();
});
it("固定搜索请求将由 Rust Web search.documents transport 拥有", () => {
const payload = buildSearchRequest({
workspaceId: "ws-3",
activeDocumentId: "doc-3",
query: "Rust Web",
filters: {
titleOnly: false,
exact: true,
onlyCurrentPage: true,
includeOcr: true,
timeField: "updated",
customRange: undefined,
},
timeRange: "any",
});
expect(payload).toMatchObject({
workspaceId: "ws-3",
documentId: "doc-3",
query: "Rust Web",
filters: {
exact: true,
includeOcr: true,
onlyCurrentPage: true,
},
});
});
});
describe("SEARCH_REQUEST_TRANSPORT_CONTRACT", () => {
it("搜索请求 contract 固定为 Rust Web transport", () => {
expect(SEARCH_REQUEST_TRANSPORT_CONTRACT).toEqual({
shellOwner: "mnote-web",
queryName: "search.documents",
endpoint: "/api/search/documents",
runtimeRole: "rust_web_transport",
});
});
});
+7
View File
@@ -4,6 +4,13 @@ import type {
DocumentSearchTimeRange,
} from "@/types/search";
export const SEARCH_REQUEST_TRANSPORT_CONTRACT = {
shellOwner: "mnote-web",
queryName: "search.documents",
endpoint: "/api/search/documents",
runtimeRole: "rust_web_transport",
} as const;
interface BuildSearchRequestArgs {
workspaceId: string | null;
activeDocumentId: string | null;
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";
describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
it("固定 3000 route 的 thin proxy 与 compat pending 边界", () => {
it("固定 3000 route 的 Rust Web gateway owner 与 legacy compat 边界", () => {
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1,
@@ -13,28 +13,34 @@ describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
expect.arrayContaining([
expect.objectContaining({
id: "tree.commands",
role: "next-thin-proxy",
role: "rust-owned",
route: "/api/tree/commands",
owner: "rust-web-gateway",
}),
expect.objectContaining({
id: "tree.stream",
role: "next-thin-proxy",
role: "rust-owned",
route: "/api/tree/stream",
owner: "rust-web-gateway",
}),
expect.objectContaining({
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
owner: "next-app-router-legacy",
}),
]),
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
"Rust command result 回包整形",
"legacy route alias 与 header 透传",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
"新页面 scaffold 文件创建",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain(
"主 tree command transport owner",
);
});
});
+12 -11
View File
@@ -1,6 +1,7 @@
export type TreeRouteBoundaryRole =
| "rust-owned"
| "next-thin-proxy"
| "legacy-thin-proxy"
| "browser-substrate"
| "compat-pending";
@@ -8,7 +9,7 @@ export type TreeRouteBoundaryItem = {
id: string;
role: TreeRouteBoundaryRole;
route: string;
owner: "rust-runtime" | "next-3000" | "convex-substrate";
owner: "rust-runtime" | "rust-web-gateway" | "next-app-router-legacy" | "convex-substrate";
description: string;
};
@@ -19,25 +20,25 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
routes: [
{
id: "tree.commands",
role: "next-thin-proxy",
role: "rust-owned",
route: "/api/tree/commands",
owner: "next-3000",
owner: "rust-web-gateway",
description:
"浏览器公开树命令入口,只负责认证、payload envelope、Rust command transport、artifact writer 调用与必要副作用调度。",
"浏览器公开树命令入口由 Rust Web gateway 拥有;legacy Next 仅保留 cookie/header compat 和浏览器 substrate 能力。",
},
{
id: "tree.stream",
role: "next-thin-proxy",
role: "rust-owned",
route: "/api/tree/stream",
owner: "next-3000",
owner: "rust-web-gateway",
description:
"浏览器 SSE/polling 入口,只转发 bridge log/domain event cursor 与 Rust 产出的 streamDelta,缺少稳定 delta 时保守 resync。",
"浏览器 SSE/polling 入口由 Rust Web gateway 拥有,legacy Next 只作为迁移期 alias。",
},
{
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
owner: "next-3000",
owner: "next-app-router-legacy",
description:
"仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。",
},
@@ -55,9 +56,7 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
nextThinProxyDuties: [
"cookie/auth 读取与 Convex client 获取",
"workspace bootstrap",
"CommandEnvelope 构造与 Rust runtime transport",
"Rust command result 回包整形",
"Rust artifact writer 调用",
"legacy route alias 与 header 透传",
"tree.subtree.move 的 sidebar snapshot preflight 数据采集",
],
browserSubstrateDuties: [
@@ -73,5 +72,7 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
"长期 streamDelta 主语义拼装",
"tree shell renderer runtime",
"tree.node.embed 的 pageReference block 结构与插入位置语义",
"主 tree command transport owner",
"主 tree stream transport owner",
],
} as const;
@@ -4,7 +4,7 @@ import {
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
export type TreeStreamKind = "snapshot" | "delta" | "resync";
export type TreeStreamKind = "snapshot" | "delta" | "resync" | "heartbeat";
export interface TreeStreamEnvelope {
stream: string;
@@ -16,7 +16,7 @@ export interface TreeStreamEnvelope {
data: unknown;
}
const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync"]);
const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync", "heartbeat"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -69,7 +69,7 @@ export function buildWorkspaceTreeStreamUrl(
): string {
const baseOrigin =
typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
const url = new URL("/api/mnote-web/stream", baseOrigin);
const url = new URL("/api/tree/events", baseOrigin);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -21,7 +21,7 @@ describe("tree-stream/protocol", () => {
expect(
buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
"http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1&cursor=evt_9",
);
});
@@ -35,7 +35,7 @@ describe("tree-stream/protocol", () => {
});
expect(
buildWorkspaceTreeStreamUrl("ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
).toBe("http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1");
});
it("解析 snapshot / delta / resync 协议消息", () => {
@@ -85,6 +85,21 @@ describe("tree-stream/protocol", () => {
projection: "sidebar_tree",
});
expect(
parseTreeStreamMessage({
eventType: "heartbeat",
rawData: JSON.stringify({
kind: "heartbeat",
workspaceId: "ws_1",
cursor: "evt_3",
}),
}),
).toMatchObject({
kind: "heartbeat",
workspaceId: "ws_1",
cursor: "evt_3",
});
expect(
parseTreeStreamMessage({
eventType: "delta",