feat: cut over rust web main shell
This commit is contained in:
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user